diff --git a/src/pymmcore_widgets/__init__.py b/src/pymmcore_widgets/__init__.py index 5b008d709..3946021a1 100644 --- a/src/pymmcore_widgets/__init__.py +++ b/src/pymmcore_widgets/__init__.py @@ -24,10 +24,10 @@ from ._presets_widget import PresetsWidget from ._properties_widget import PropertiesWidget from ._property_browser import PropertyBrowser -from ._property_widget import PropertyWidget from ._shutter_widget import ShuttersWidget from ._snap_button_widget import SnapButton from ._stage_widget import StageWidget +from .device_properties._property_widget import PropertyWidget from .hcwizard import ConfigWizard from .mda import MDAWidget from .useq_widgets import ( diff --git a/src/pymmcore_widgets/_device_property_table.py b/src/pymmcore_widgets/_device_property_table.py index 9fa8473ba..3d630e00b 100644 --- a/src/pymmcore_widgets/_device_property_table.py +++ b/src/pymmcore_widgets/_device_property_table.py @@ -10,7 +10,7 @@ from qtpy.QtWidgets import QAbstractScrollArea, QTableWidget, QTableWidgetItem, QWidget from superqt.fonticon import icon -from pymmcore_widgets._property_widget import PropertyWidget +from pymmcore_widgets.device_properties._property_widget import PropertyWidget ICONS: dict[DeviceType, str] = { DeviceType.Any: MDI6.devices, diff --git a/src/pymmcore_widgets/_group_preset_widget/_add_first_preset_widget.py b/src/pymmcore_widgets/_group_preset_widget/_add_first_preset_widget.py index 34818c7a4..c8327e226 100644 --- a/src/pymmcore_widgets/_group_preset_widget/_add_first_preset_widget.py +++ b/src/pymmcore_widgets/_group_preset_widget/_add_first_preset_widget.py @@ -17,8 +17,8 @@ QWidget, ) -from pymmcore_widgets._property_widget import PropertyWidget from pymmcore_widgets._util import block_core +from pymmcore_widgets.device_properties._property_widget import PropertyWidget class AddFirstPresetWidget(QDialog): diff --git a/src/pymmcore_widgets/_group_preset_widget/_add_preset_widget.py b/src/pymmcore_widgets/_group_preset_widget/_add_preset_widget.py index 155629acc..b32b0499c 100644 --- a/src/pymmcore_widgets/_group_preset_widget/_add_preset_widget.py +++ b/src/pymmcore_widgets/_group_preset_widget/_add_preset_widget.py @@ -19,8 +19,8 @@ QWidget, ) -from pymmcore_widgets._property_widget import PropertyWidget from pymmcore_widgets._util import block_core +from pymmcore_widgets.device_properties._property_widget import PropertyWidget class AddPresetWidget(QDialog): diff --git a/src/pymmcore_widgets/_group_preset_widget/_edit_preset_widget.py b/src/pymmcore_widgets/_group_preset_widget/_edit_preset_widget.py index 4b868c679..bbeea06bf 100644 --- a/src/pymmcore_widgets/_group_preset_widget/_edit_preset_widget.py +++ b/src/pymmcore_widgets/_group_preset_widget/_edit_preset_widget.py @@ -21,8 +21,8 @@ ) from superqt.utils import signals_blocked -from pymmcore_widgets._property_widget import PropertyWidget from pymmcore_widgets._util import block_core +from pymmcore_widgets.device_properties._property_widget import PropertyWidget class EditPresetWidget(QDialog): diff --git a/src/pymmcore_widgets/_group_preset_widget/_group_preset_table_widget.py b/src/pymmcore_widgets/_group_preset_widget/_group_preset_table_widget.py index e188140f0..b8f95bebe 100644 --- a/src/pymmcore_widgets/_group_preset_widget/_group_preset_table_widget.py +++ b/src/pymmcore_widgets/_group_preset_widget/_group_preset_table_widget.py @@ -21,8 +21,8 @@ from pymmcore_widgets._core import load_system_config from pymmcore_widgets._presets_widget import PresetsWidget -from pymmcore_widgets._property_widget import PropertyWidget from pymmcore_widgets._util import block_core +from pymmcore_widgets.device_properties._property_widget import PropertyWidget from ._add_group_widget import AddGroupWidget from ._add_preset_widget import AddPresetWidget diff --git a/src/pymmcore_widgets/_pixel_configuration_widget.py b/src/pymmcore_widgets/_pixel_configuration_widget.py index ed8d2d105..ee87ba362 100644 --- a/src/pymmcore_widgets/_pixel_configuration_widget.py +++ b/src/pymmcore_widgets/_pixel_configuration_widget.py @@ -29,7 +29,7 @@ from pymmcore_widgets._device_property_table import DevicePropertyTable from pymmcore_widgets._device_type_filter import DeviceTypeFilters -from pymmcore_widgets._property_widget import PropertyWidget +from pymmcore_widgets.device_properties._property_widget import PropertyWidget from pymmcore_widgets.useq_widgets import DataTable, DataTableWidget from pymmcore_widgets.useq_widgets._column_info import FloatColumn, TextColumn diff --git a/src/pymmcore_widgets/_properties_widget.py b/src/pymmcore_widgets/_properties_widget.py index de3ed2854..ae744c4a0 100644 --- a/src/pymmcore_widgets/_properties_widget.py +++ b/src/pymmcore_widgets/_properties_widget.py @@ -10,7 +10,7 @@ from pymmcore_plus import CMMCorePlus from qtpy.QtWidgets import QGridLayout, QLabel, QWidget -from ._property_widget import PropertyWidget +from .device_properties._property_widget import PropertyWidget if TYPE_CHECKING: import re diff --git a/src/pymmcore_widgets/config_presets/__init__.py b/src/pymmcore_widgets/config_presets/__init__.py new file mode 100644 index 000000000..933a83656 --- /dev/null +++ b/src/pymmcore_widgets/config_presets/__init__.py @@ -0,0 +1 @@ +"""Widgets related to configuration groups and presets.""" diff --git a/src/pymmcore_widgets/device_properties/__init__.py b/src/pymmcore_widgets/device_properties/__init__.py new file mode 100644 index 000000000..8f4d0b0b5 --- /dev/null +++ b/src/pymmcore_widgets/device_properties/__init__.py @@ -0,0 +1 @@ +"""Widgets related to device properties.""" diff --git a/src/pymmcore_widgets/_property_widget.py b/src/pymmcore_widgets/device_properties/_property_widget.py similarity index 92% rename from src/pymmcore_widgets/_property_widget.py rename to src/pymmcore_widgets/device_properties/_property_widget.py index 72c0e5a29..814fe45dc 100644 --- a/src/pymmcore_widgets/_property_widget.py +++ b/src/pymmcore_widgets/device_properties/_property_widget.py @@ -1,14 +1,16 @@ +"""PropertyWidget. + +This module provides a widget to display and control a specified mmcore device property, +using the appropriate widget for the property type and allowed choices. +""" + from __future__ import annotations import contextlib -from typing import Any, Callable, Protocol, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, Protocol, TypeVar, cast import pymmcore -from pymmcore_plus import ( - CMMCorePlus, - DeviceType, - PropertyType, -) +from pymmcore_plus import CMMCorePlus, DeviceType, PropertyType from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( QCheckBox, @@ -25,30 +27,201 @@ STATE = pymmcore.g_Keyword_State LABEL = pymmcore.g_Keyword_Label +__all__ = ["PropertyWidget"] + +if TYPE_CHECKING: + + class PSignalInstance(Protocol): + """The protocol expected of a signal instance.""" + + def connect(self, callback: Callable) -> Callable: ... + def disconnect(self, callback: Callable) -> None: ... + def emit(self, *args: Any) -> None: ... + + class PPropValueWidget(Protocol): + """The protocol expected of a ValueWidget.""" + + valueChanged: PSignalInstance + destroyed: PSignalInstance + + def value(self) -> str | float: ... + def setValue(self, val: str | float) -> None: ... + def setEnabled(self, enabled: bool) -> None: ... + def setParent(self, parent: QWidget | None) -> None: ... + def deleteLater(self) -> None: ... + + +# ----------------------------------------------------------------------- +# Main public facing QWidget. +# ----------------------------------------------------------------------- + + +class PropertyWidget(QWidget): + """A widget to display and control a specified mmcore device property. + + Parameters + ---------- + device_label : str + Device label + prop_name : str + Property name + parent : QWidget | None + Optional parent widget. By default, None. + mmcore : CMMCorePlus | None + Optional [`pymmcore_plus.CMMCorePlus`][] micromanager core. + By default, None. If not specified, the widget will use the active + (or create a new) + [`CMMCorePlus.instance`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.instance]. + connect_core : bool + Whether to connect the widget to the core. If False, the widget will not + update the core when the value changes. By default, True. + + Raises + ------ + ValueError + If the `device_label` is not loaded, or does not have a property `prop_name`. + """ + + _value_widget: PPropValueWidget + valueChanged = Signal(object) + + def __init__( + self, + device_label: str, + prop_name: str, + *, + parent: QWidget | None = None, + mmcore: CMMCorePlus | None = None, + connect_core: bool = True, + ) -> None: + super().__init__(parent=parent) + + self._mmc = mmcore or CMMCorePlus.instance() + + if device_label not in self._mmc.getLoadedDevices(): + raise ValueError(f"Device not loaded: {device_label!r}") + + if not self._mmc.hasProperty(device_label, prop_name): + names = self._mmc.getDevicePropertyNames(device_label) + raise ValueError( + f"Device {device_label!r} has no property {prop_name!r}. " + f"Available property names include: {names}" + ) + + self._updates_core: bool = connect_core # whether to update the core on change + self._device_label = device_label + self._prop_name = prop_name + + self.setLayout(QHBoxLayout()) + self.layout().setContentsMargins(0, 0, 0, 0) + + # Create the widget based on property type and allowed choices + self._value_widget = _creat_prop_widget(self._mmc, device_label, prop_name) + # set current value from core + self._try_update_from_core() + + self._mmc.events.propertyChanged.connect(self._on_core_change) + self._value_widget.valueChanged.connect(self._on_value_widget_change) + + self.layout().addWidget(cast(QWidget, self._value_widget)) + self.destroyed.connect(self._disconnect) + + def _try_update_from_core(self) -> None: + # set current value from core, ignoring errors + with contextlib.suppress(RuntimeError, ValueError): + self._value_widget.setValue(self._mmc.getProperty(*self._dp)) -# fmt: off -class PSignalInstance(Protocol): - """The protocol expected of a signal instance.""" + # disable for any device init state besides 0 (Uninitialized) + if hasattr(self._mmc, "getDeviceInitializationState") and ( + self._mmc.isPropertyPreInit(self._device_label, self._prop_name) + and self._mmc.getDeviceInitializationState(self._device_label) + ): + self.setDisabled(True) - def connect(self, callback: Callable) -> Callable: ... - def disconnect(self, callback: Callable) -> None: ... - def emit(self, *args: Any) -> None: ... + # connect events and queue for disconnection on widget destroyed + def _on_core_change(self, dev_label: str, prop_name: str, new_val: Any) -> None: + if dev_label == self._device_label and prop_name == self._prop_name: + with utils.signals_blocked(self._value_widget): + self._value_widget.setValue(new_val) + def _on_value_widget_change(self, value: Any) -> None: + if not self._updates_core: + return + try: + self._mmc.setProperty(self._device_label, self._prop_name, value) + except (RuntimeError, ValueError): + # if there's an error when updating mmcore, reset widget value to mmcore + self._try_update_from_core() + + def _disconnect(self) -> None: + with contextlib.suppress(RuntimeError): + self._mmc.events.propertyChanged.disconnect(self._on_core_change) -class PPropValueWidget(Protocol): - """The protocol expected of a ValueWidget.""" + def value(self) -> Any: + """Get value. + + Return the current value of the *widget* (which should match mmcore). + """ + return self._value_widget.value() + + def connectCore(self, mmcore: CMMCorePlus | None = None) -> None: + """Connect to core. + + Connect the widget to the core. This is the default state. + """ + self._updates_core = True + if mmcore is not None and mmcore is not self._mmc: + self._mmc = mmcore - valueChanged: PSignalInstance - destroyed: PSignalInstance - def value(self) -> str | float: ... - def setValue(self, val: str | float) -> None: ... - def setEnabled(self, enabled: bool) -> None: ... - def setParent(self, parent: QWidget | None) -> None: ... - def deleteLater(self) -> None: ... -# fmt: on + def disconnectCore(self) -> None: + """Disconnect from core. + + Disconnect the widget from the core. This will prevent the widget + from updating the core when the value changes. + """ + self._updates_core = False + + def setValue(self, value: Any) -> None: + """Set the current value of the *widget* (which should match mmcore).""" + self._value_widget.setValue(value) + + def allowedValues(self) -> tuple[str, ...]: + """Return tuple of allowable values if property is categorical.""" + # this will have already been grabbed from mmcore on creation, and will + # have also taken into account the restrictions in the State/Label property + # of state devices. So check for the _allowed attribute on the widget. + return tuple(getattr(self._value_widget, "_allowed", ())) + + def refresh(self) -> None: + """Update the value of the widget from mmcore. + + (If all goes well this shouldn't be necessary, but if a propertyChanged + event is missed, this can be used). + """ + with utils.signals_blocked(self._value_widget): + self._try_update_from_core() + + def propertyType(self) -> PropertyType: + """Return property type.""" + return self._mmc.getPropertyType(*self._dp) + + def deviceType(self) -> DeviceType: + """Return property type.""" + return self._mmc.getDeviceType(self._device_label) + + def isReadOnly(self) -> bool: + """Return True if property is read only.""" + return self._mmc.isPropertyReadOnly(*self._dp) + + @property + def _dp(self) -> tuple[str, str]: + """Commonly requested pair for mmcore calls.""" + return self._device_label, self._prop_name # ----------------------------------------------------------------------- +# Supporting code and widgets +# # These widgets all implement PPropValueWidget for various PropertyTypes. # ----------------------------------------------------------------------- @@ -258,172 +431,4 @@ def _creat_prop_widget(mmcore: CMMCorePlus, dev: str, prop: str) -> PPropValueWi wdg.setMaximum(wdg.type_cast(mmcore.getPropertyUpperLimit(dev, prop))) else: wdg = StringWidget() - return cast(PPropValueWidget, wdg) - - -# ----------------------------------------------------------------------- -# Main public facing QWidget. -# ----------------------------------------------------------------------- - - -class PropertyWidget(QWidget): - """A widget to display and control a specified mmcore device property. - - Parameters - ---------- - device_label : str - Device label - prop_name : str - Property name - parent : QWidget | None - Optional parent widget. By default, None. - mmcore : CMMCorePlus | None - Optional [`pymmcore_plus.CMMCorePlus`][] micromanager core. - By default, None. If not specified, the widget will use the active - (or create a new) - [`CMMCorePlus.instance`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.instance]. - connect_core : bool - Whether to connect the widget to the core. If False, the widget will not - update the core when the value changes. By default, True. - - Raises - ------ - ValueError - If the `device_label` is not loaded, or does not have a property `prop_name`. - """ - - _value_widget: PPropValueWidget - valueChanged = Signal(object) - - def __init__( - self, - device_label: str, - prop_name: str, - *, - parent: QWidget | None = None, - mmcore: CMMCorePlus | None = None, - connect_core: bool = True, - ) -> None: - super().__init__(parent=parent) - - self._mmc = mmcore or CMMCorePlus.instance() - - if device_label not in self._mmc.getLoadedDevices(): - raise ValueError(f"Device not loaded: {device_label!r}") - - if not self._mmc.hasProperty(device_label, prop_name): - names = self._mmc.getDevicePropertyNames(device_label) - raise ValueError( - f"Device {device_label!r} has no property {prop_name!r}. " - f"Available property names include: {names}" - ) - - self._updates_core: bool = connect_core # whether to update the core on change - self._device_label = device_label - self._prop_name = prop_name - - self.setLayout(QHBoxLayout()) - self.layout().setContentsMargins(0, 0, 0, 0) - - # Create the widget based on property type and allowed choices - self._value_widget = _creat_prop_widget(self._mmc, device_label, prop_name) - # set current value from core - self._try_update_from_core() - - self._mmc.events.propertyChanged.connect(self._on_core_change) - self._value_widget.valueChanged.connect(self._on_value_widget_change) - - self.layout().addWidget(cast(QWidget, self._value_widget)) - self.destroyed.connect(self._disconnect) - - def _try_update_from_core(self) -> None: - # set current value from core, ignoring errors - with contextlib.suppress(RuntimeError, ValueError): - self._value_widget.setValue(self._mmc.getProperty(*self._dp)) - - # disable for any device init state besides 0 (Uninitialized) - if hasattr(self._mmc, "getDeviceInitializationState") and ( - self._mmc.isPropertyPreInit(self._device_label, self._prop_name) - and self._mmc.getDeviceInitializationState(self._device_label) - ): - self.setDisabled(True) - - # connect events and queue for disconnection on widget destroyed - def _on_core_change(self, dev_label: str, prop_name: str, new_val: Any) -> None: - if dev_label == self._device_label and prop_name == self._prop_name: - with utils.signals_blocked(self._value_widget): - self._value_widget.setValue(new_val) - - def _on_value_widget_change(self, value: Any) -> None: - if not self._updates_core: - return - try: - self._mmc.setProperty(self._device_label, self._prop_name, value) - except (RuntimeError, ValueError): - # if there's an error when updating mmcore, reset widget value to mmcore - self._try_update_from_core() - - def _disconnect(self) -> None: - with contextlib.suppress(RuntimeError): - self._mmc.events.propertyChanged.disconnect(self._on_core_change) - - def value(self) -> Any: - """Get value. - - Return the current value of the *widget* (which should match mmcore). - """ - return self._value_widget.value() - - def connectCore(self, mmcore: CMMCorePlus | None = None) -> None: - """Connect to core. - - Connect the widget to the core. This is the default state. - """ - self._updates_core = True - if mmcore is not None and mmcore is not self._mmc: - self._mmc = mmcore - - def disconnectCore(self) -> None: - """Disconnect from core. - - Disconnect the widget from the core. This will prevent the widget - from updating the core when the value changes. - """ - self._updates_core = False - - def setValue(self, value: Any) -> None: - """Set the current value of the *widget* (which should match mmcore).""" - self._value_widget.setValue(value) - - def allowedValues(self) -> tuple[str, ...]: - """Return tuple of allowable values if property is categorical.""" - # this will have already been grabbed from mmcore on creation, and will - # have also taken into account the restrictions in the State/Label property - # of state devices. So check for the _allowed attribute on the widget. - return tuple(getattr(self._value_widget, "_allowed", ())) - - def refresh(self) -> None: - """Update the value of the widget from mmcore. - - (If all goes well this shouldn't be necessary, but if a propertyChanged - event is missed, this can be used). - """ - with utils.signals_blocked(self._value_widget): - self._try_update_from_core() - - def propertyType(self) -> PropertyType: - """Return property type.""" - return self._mmc.getPropertyType(*self._dp) - - def deviceType(self) -> DeviceType: - """Return property type.""" - return self._mmc.getDeviceType(self._device_label) - - def isReadOnly(self) -> bool: - """Return True if property is read only.""" - return self._mmc.isPropertyReadOnly(*self._dp) - - @property - def _dp(self) -> tuple[str, str]: - """Commonly requested pair for mmcore calls.""" - return self._device_label, self._prop_name + return wdg # type: ignore [no-any-return] diff --git a/src/pymmcore_widgets/hcwizard/_simple_prop_table.py b/src/pymmcore_widgets/hcwizard/_simple_prop_table.py index ca1b3bc2b..a1abc5cbb 100644 --- a/src/pymmcore_widgets/hcwizard/_simple_prop_table.py +++ b/src/pymmcore_widgets/hcwizard/_simple_prop_table.py @@ -8,7 +8,7 @@ from qtpy.QtCore import Signal from qtpy.QtWidgets import QComboBox, QTableWidget, QTableWidgetItem, QWidget -from pymmcore_widgets._property_widget import PropertyWidget +from pymmcore_widgets.device_properties._property_widget import PropertyWidget class PortSelector(QComboBox):