From 2191794cdef874063e6da95609e00a49297e1f46 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 16:03:54 -0400 Subject: [PATCH 01/14] refactor: pull in just the model from 452 --- src/pymmcore_widgets/_icons.py | 35 +- src/pymmcore_widgets/_models/__init__.py | 37 ++ .../_models/_base_tree_model.py | 170 ++++++++ .../_models/_config_group_pivot_model.py | 246 +++++++++++ .../_models/_core_functions.py | 129 ++++++ .../_models/_py_config_model.py | 227 +++++++++++ .../_q_config_model.py} | 384 ++++++++++-------- .../_models/_q_device_prop_model.py | 375 +++++++++++++++++ .../config_presets/__init__.py | 2 - .../config_presets/_qmodel/__init__.py | 0 .../_views/_config_groups_tree.py | 7 +- .../_views/_config_presets_table.py | 258 +++--------- .../_views/_property_setting_delegate.py | 22 +- .../_device_property_table.py | 4 +- src/pymmcore_widgets/hcwizard/devices_page.py | 10 +- tests/test_config_groups_model.py | 56 +-- tests/test_config_groups_widgets.py | 5 +- 17 files changed, 1555 insertions(+), 412 deletions(-) create mode 100644 src/pymmcore_widgets/_models/__init__.py create mode 100644 src/pymmcore_widgets/_models/_base_tree_model.py create mode 100644 src/pymmcore_widgets/_models/_config_group_pivot_model.py create mode 100644 src/pymmcore_widgets/_models/_core_functions.py create mode 100644 src/pymmcore_widgets/_models/_py_config_model.py rename src/pymmcore_widgets/{config_presets/_qmodel/_config_model.py => _models/_q_config_model.py} (58%) create mode 100644 src/pymmcore_widgets/_models/_q_device_prop_model.py delete mode 100644 src/pymmcore_widgets/config_presets/_qmodel/__init__.py diff --git a/src/pymmcore_widgets/_icons.py b/src/pymmcore_widgets/_icons.py index 29a3c9fd6..7e4bc7d36 100644 --- a/src/pymmcore_widgets/_icons.py +++ b/src/pymmcore_widgets/_icons.py @@ -1,9 +1,11 @@ from __future__ import annotations +from enum import Enum + from pymmcore_plus import CMMCorePlus, DeviceType from superqt import QIconifyIcon -ICONS: dict[DeviceType, str] = { +DEVICE_TYPE_ICON: dict[DeviceType, str] = { DeviceType.Any: "mdi:devices", DeviceType.AutoFocus: "mdi:focus-auto", DeviceType.Camera: "mdi:camera", @@ -24,6 +26,35 @@ } +class StandardIcon(str, Enum): + READ_ONLY = "fluent:edit-off-20-regular" + PRE_INIT = "mynaui:letter-p-diamond" + EXPAND = "mdi:expand-horizontal" + COLLAPSE = "mdi:collapse-horizontal" + TABLE = "mdi:table" + TREE = "ph:tree-view" + FOLDER_ADD = "fluent:folder-add-24-regular" + DOCUMENT_ADD = "fluent:document-add-24-regular" + DELETE = "fluent:delete-24-regular" + COPY = "fluent:save-copy-24-regular" + TRANSPOSE = "carbon:transpose" + CONFIG_GROUP = "mdi:folder-settings-variant-outline" + CONFIG_PRESET = "mdi:file-settings-cog-outline" + HELP = "mdi:help-circle-outline" + CHANNEL_GROUP = "mynaui:letter-c-waves-solid" + SYSTEM_GROUP = "mdi:power" + STARTUP = "ic:baseline-power" + SHUTDOWN = "ic:baseline-power-off" + UNDO = "mdi:undo" + REDO = "mdi:redo" + + def icon(self, color: str = "gray") -> QIconifyIcon: + return QIconifyIcon(self.value, color=color) + + def __str__(self) -> str: + return self.value + + def get_device_icon( device_type_or_name: DeviceType | str, color: str = "gray" ) -> QIconifyIcon | None: @@ -34,6 +65,6 @@ def get_device_icon( device_type = DeviceType.Unknown else: device_type = device_type_or_name - if icon_string := ICONS.get(device_type): + if icon_string := DEVICE_TYPE_ICON.get(device_type): return QIconifyIcon(icon_string, color=color) return None diff --git a/src/pymmcore_widgets/_models/__init__.py b/src/pymmcore_widgets/_models/__init__.py new file mode 100644 index 000000000..8c24ac8e7 --- /dev/null +++ b/src/pymmcore_widgets/_models/__init__.py @@ -0,0 +1,37 @@ +from ._config_group_pivot_model import ConfigGroupPivotModel +from ._core_functions import ( + get_available_devices, + get_config_groups, + get_config_presets, + get_loaded_devices, + get_preset_settings, + get_property_info, +) +from ._py_config_model import ( + ConfigGroup, + ConfigPreset, + Device, + DevicePropertySetting, + PixelSizeConfigs, + PixelSizePreset, +) +from ._q_config_model import QConfigGroupsModel +from ._q_device_prop_model import QDevicePropertyModel + +__all__ = [ + "ConfigGroup", + "ConfigGroupPivotModel", + "ConfigPreset", + "Device", + "DevicePropertySetting", + "PixelSizeConfigs", + "PixelSizePreset", + "QConfigGroupsModel", + "QDevicePropertyModel", + "get_available_devices", + "get_config_groups", + "get_config_presets", + "get_loaded_devices", + "get_preset_settings", + "get_property_info", +] diff --git a/src/pymmcore_widgets/_models/_base_tree_model.py b/src/pymmcore_widgets/_models/_base_tree_model.py new file mode 100644 index 000000000..1905daa4c --- /dev/null +++ b/src/pymmcore_widgets/_models/_base_tree_model.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from typing import overload + +from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt +from typing_extensions import Self + +from ._py_config_model import ConfigGroup, ConfigPreset, Device, DevicePropertySetting + +NULL_INDEX = QModelIndex() + + +class _Node: + """Generic tree node that wraps a ConfigGroup, ConfigPreset, or Setting.""" + + __slots__ = ( + "check_state", + "children", + "name", + "parent", + "payload", + ) + + @classmethod + def create( + cls, + payload: ConfigGroup | ConfigPreset | DevicePropertySetting | Device, + parent: _Node | None = None, + recursive: bool = True, + ) -> Self: + """Create a new _Node with the given name and payload.""" + if isinstance(payload, DevicePropertySetting): + name = payload.property_name + elif isinstance(payload, Device): + name = payload.label + else: + name = payload.name + + node = cls(name, payload, parent) + if recursive: + if isinstance(payload, ConfigGroup): + for p in payload.presets.values(): + node.children.append(_Node.create(p, node)) + elif isinstance(payload, ConfigPreset): + for s in payload.settings: + node.children.append(_Node.create(s, node)) + elif isinstance(payload, Device): + for prop in payload.properties: + node.children.append(_Node.create(prop, node)) + return node + + def __init__( + self, + name: str, + payload: ConfigGroup + | ConfigPreset + | DevicePropertySetting + | Device + | None = None, + parent: _Node | None = None, + ) -> None: + self.name = name + self.payload = payload + self.parent = parent + self.check_state = Qt.CheckState.Unchecked + self.children: list[_Node] = [] + + # convenience ------------------------------------------------------------ + + @property + def siblings(self) -> list[_Node]: + if self.parent is None: + return [] + return [x for x in self.parent.children if x is not self] + + def num_children(self) -> int: + return len(self.children) + + def row_in_parent(self) -> int: + if self.parent is None: + return -1 + try: + return self.parent.children.index(self) + except ValueError: # pragma: no cover + return -1 + + # type helpers ----------------------------------------------------------- + + @property + def is_group(self) -> bool: + return isinstance(self.payload, ConfigGroup) + + @property + def is_preset(self) -> bool: + return isinstance(self.payload, ConfigPreset) + + @property + def is_setting(self) -> bool: + return isinstance(self.payload, DevicePropertySetting) + + @property + def is_device(self) -> bool: + return isinstance(self.payload, Device) + + +class _BaseTreeModel(QAbstractItemModel): + """Thin abstract tree model. + + Sub-classes should implement at least the following methods: + + * columnCount(self, parent: QModelIndex) -> int: ... + * data(self, index: QModelIndex, role: int = ...) -> Any: + * setData(self, index: QModelIndex, value: Any, role: int) -> bool: + * flags(self, index: QModelIndex) -> Qt.ItemFlag: + """ + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self._root = _Node("", None) + + def _node_from_index(self, index: QModelIndex | None) -> _Node: + if ( + index + and index.isValid() + and isinstance((node := index.internalPointer()), _Node) + ): + # return the node if index is valid + return node + # otherwise return the root node + return self._root + + # # ---------- Qt plumbing ---------------------------------------------- + + def rowCount(self, parent: QModelIndex = NULL_INDEX) -> int: + # Only column 0 should have children in tree models + if parent is not None and parent.isValid() and parent.column() != 0: + return 0 + return self._node_from_index(parent).num_children() + + def index( + self, row: int, column: int = 0, parent: QModelIndex = NULL_INDEX + ) -> QModelIndex: + """Return the index of the item specified by row, column and parent index.""" + parent_node = self._node_from_index(parent) + if 0 <= row < len(parent_node.children): + return self.createIndex(row, column, parent_node.children[row]) + return QModelIndex() # pragma: no cover + + @overload + def parent(self, child: QModelIndex) -> QModelIndex: ... + @overload + def parent(self) -> QObject | None: ... + def parent(self, child: QModelIndex | None = None) -> QModelIndex | QObject | None: + """Return the parent of the model item with the given index. + + If the item has no parent, an invalid QModelIndex is returned. + """ + if child is None: # pragma: no cover + return None + node = self._node_from_index(child) + if ( + node is self._root + or not (parent_node := node.parent) + or parent_node is self._root + ): + return QModelIndex() + + # A common convention used in models that expose tree data structures is that + # only items in the first column have children. + return self.createIndex(parent_node.row_in_parent(), 0, parent_node) diff --git a/src/pymmcore_widgets/_models/_config_group_pivot_model.py b/src/pymmcore_widgets/_models/_config_group_pivot_model.py new file mode 100644 index 000000000..08b8a4507 --- /dev/null +++ b/src/pymmcore_widgets/_models/_config_group_pivot_model.py @@ -0,0 +1,246 @@ +from typing import Any + +from qtpy.QtCore import QAbstractTableModel, QModelIndex, QSize, Qt +from qtpy.QtWidgets import QWidget + +from pymmcore_widgets._icons import get_device_icon + +from ._py_config_model import ConfigPreset, DevicePropertySetting +from ._q_config_model import QConfigGroupsModel + + +class ConfigGroupPivotModel(QAbstractTableModel): + """Pivot a single ConfigGroup into rows=Device/Property, cols=Presets.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._src: QConfigGroupsModel | None = None + self._gidx: QModelIndex | None = None + self._presets: list[ConfigPreset] = [] + self._rows: list[tuple[str, str]] = [] # (device_name, property_name) + self._data: dict[tuple[int, int], DevicePropertySetting] = {} + + def sourceModel(self) -> QConfigGroupsModel | None: + """Return the source model.""" + return self._src + + def setSourceModel(self, src_model: QConfigGroupsModel) -> None: + """Set the source model and rebuild the matrix.""" + if not isinstance(src_model, QConfigGroupsModel): # pragma: no cover + raise TypeError("Source model must be an instance of QConfigGroupsModel.") + self._src = src_model + + # -> keep the pivot up-to-date whenever the tree model changes + src_model.modelReset.connect(self._rebuild) + src_model.rowsInserted.connect(self._rebuild) + src_model.rowsRemoved.connect(self._rebuild) + src_model.dataChanged.connect(self._on_source_data_changed) + + def setGroup(self, group_name_or_index: str | QModelIndex) -> None: + """Set the group index to pivot and rebuild the matrix.""" + if self._src is None: # pragma: no cover + raise ValueError("Source model is not set. Call setSourceModel first.") + if not isinstance(group_name_or_index, QModelIndex): + self._gidx = self._src.index_for_group(group_name_or_index) + else: + if not group_name_or_index.isValid(): # pragma: no cover + raise ValueError("Invalid QModelIndex provided for group selection.") + self._gidx = group_name_or_index + self._rebuild() + + def setData( + self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole + ) -> bool: + """Set data for a specific cell in the pivot table.""" + if ( + role != Qt.ItemDataRole.EditRole + or not index.isValid() + or self._src is None + or self._gidx is None + or (row := index.row()) >= len(self._rows) + or (col := index.column()) >= len(self._presets) + ): + return False # pragma: no cover + + # Get the preset and device/property for this cell + preset = self._presets[col] + dev_prop = self._rows[row] + # Create or update the setting + # Update our local data + self._data[(row, col)] = setting = DevicePropertySetting( + device=dev_prop[0], property_name=dev_prop[1], value=str(value) + ) + + # Update the preset's settings list + preset_settings = list(preset.settings) + + # Find existing setting or add new one + for i, existing_setting in enumerate(preset_settings): + existing_key = ( + existing_setting.device_label, + existing_setting.property_name, + ) + if existing_key == dev_prop: + preset_settings[i] = setting + break + else: + preset_settings.append(setting) + + # Find the preset index in the source model and update it + preset_idx = self._src.index_for_preset(self._gidx, preset.name) + if preset_idx.isValid(): + self._src.update_preset_settings(preset_idx, preset_settings) + + # Emit dataChanged signal for the specific cell + self._src.dataChanged.emit(preset_idx, preset_idx, [role]) + return True + + # ---------------------------------------------------------------- build -- + + def _rebuild(self) -> None: # slot signature is flexible + if self._gidx is None: # nothing selected yet + return # pragma: no cover + self.beginResetModel() + + self._presets = [] + self._rows = [] + self._data.clear() + try: + node = self._gidx.internalPointer() + if not node: + return + self._presets = [child.payload for child in node.children] + keys = (setting.key() for p in self._presets for setting in p.settings) + self._rows = list(dict.fromkeys(keys, None)) # unique (device, prop) pairs + + self._data.clear() + for col, preset in enumerate(self._presets): + for row, (device, prop) in enumerate(self._rows): + for s in preset.settings: + if s.key() == (device, prop): + self._data[(row, col)] = s + break + finally: + self.endResetModel() + + # --------------------------------------------------------- Qt overrides -- + + def rowCount(self, parent: QModelIndex | None = None) -> int: + if parent is not None and parent.isValid(): + return 0 + return len(self._rows) + + def columnCount(self, parent: QModelIndex | None = None) -> int: + if parent is not None and parent.isValid(): + return 0 + return len(self._presets) + + def headerData( + self, + section: int, + orient: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> Any: + if role == Qt.ItemDataRole.DisplayRole and section < len(self._presets): + if orient == Qt.Orientation.Horizontal: + return self._presets[section].name + return "-".join(self._rows[section]) + elif role == Qt.ItemDataRole.DecorationRole and section < len(self._rows): + if orient == Qt.Orientation.Vertical: + try: + dev, _prop = self._rows[section] + except IndexError: # pragma: no cover + return None + if icon := get_device_icon(dev): + return icon.pixmap(QSize(16, 16)) + return None + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid(): # pragma: no cover + return None + + setting = self._data.get((index.row(), index.column())) + if setting is None: + return None + + if role == Qt.ItemDataRole.UserRole: + return setting + + if role in ( + Qt.ItemDataRole.DisplayRole, + Qt.ItemDataRole.EditRole, + ): + return setting.value if setting else None + return None + + # make editable + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + if not index.isValid(): # pragma: no cover + return Qt.ItemFlag.NoItemFlags + return ( + Qt.ItemFlag.ItemIsEnabled + | Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsEditable + ) + + def get_source_index_for_column(self, column: int) -> QModelIndex: + """Get the source index for a given column in the pivot model.""" + if self._src is None or self._gidx is None: # pragma: no cover + raise ValueError("Source model or group index is not set.") + if column < 0 or column >= len(self._presets): # pragma: no cover + raise IndexError("Column index out of range.") + + preset = self._presets[column] + preset_idx = self._src.index_for_preset(self._gidx, preset.name) + return preset_idx + + def _on_source_data_changed( + self, + top_left: QModelIndex, + bottom_right: QModelIndex, + roles: list[int] | None = None, + ) -> None: + """Handle dataChanged signals from the source model.""" + if self._should_rebuild_for_changes(top_left, bottom_right): + self._rebuild() + + def _should_rebuild_for_changes( + self, top_left: QModelIndex, bottom_right: QModelIndex + ) -> bool: + """Determine if model changes require rebuilding the pivot.""" + if self._gidx is None or self._src is None: + return False + + tl_col = top_left.column() + tl_par = top_left.parent() + gid_row = self._gidx.row() + for row in range(top_left.row(), bottom_right.row() + 1): + changed_index = self._src.index(row, tl_col, tl_par) + # Skip group metadata changes (at root level with our group row) + if tl_par.isValid() or row != gid_row: + if self._is_within_current_group(changed_index): + # Preset or setting data changed, rebuild needed + return True + return False + + def _is_within_current_group(self, index: QModelIndex) -> bool: + """Check if the given index is within the currently displayed group.""" + current_group_row = self._gidx.row() # type: ignore[union-attr] + + # Walk up the parent hierarchy + check_index = index + while check_index.isValid(): + parent = check_index.parent() + + # At root level: check if this is our group + if not parent.isValid(): + return check_index.row() == current_group_row # type: ignore[no-any-return] + + # Parent at root level: check if parent is our group + if not parent.parent().isValid(): + return parent.row() == current_group_row # type: ignore[no-any-return] + + # Move up one level + check_index = parent + + return False diff --git a/src/pymmcore_widgets/_models/_core_functions.py b/src/pymmcore_widgets/_models/_core_functions.py new file mode 100644 index 000000000..99d9e2063 --- /dev/null +++ b/src/pymmcore_widgets/_models/_core_functions.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from functools import cache +from typing import TYPE_CHECKING + +from pymmcore_plus import CMMCorePlus, DeviceType + +from ._py_config_model import ConfigGroup, ConfigPreset, Device, DevicePropertySetting + +if TYPE_CHECKING: + from collections.abc import Container, Iterable + + +# ---------------------------------- + + +@cache +def _get_device(core: CMMCorePlus, label: str) -> Device: + """Get a Device model for the given label.""" + return Device( + label=label, + name=core.getDeviceName(label), + description=core.getDeviceDescription(label), + library=core.getDeviceLibrary(label), + type=core.getDeviceType(label), + ) + + +def get_config_groups(core: CMMCorePlus) -> Iterable[ConfigGroup]: + """Get the model for configuration groups.""" + channel_group = core.getChannelGroup() + for group in core.getAvailableConfigGroups(): + group_model = ConfigGroup(name=group, is_channel_group=(group == channel_group)) + for preset_model in get_config_presets(core, group): + preset_model.parent = group_model + group_model.presets[preset_model.name] = preset_model + yield group_model + + +def get_config_presets(core: CMMCorePlus, group: str) -> Iterable[ConfigPreset]: + """Get all available configuration presets for a group.""" + for preset in core.getAvailableConfigs(group): + preset_model = ConfigPreset(name=preset) + for prop_model in get_preset_settings(core, group, preset): + prop_model.parent = preset_model + preset_model.settings.append(prop_model) + yield preset_model + + +def get_preset_settings( + core: CMMCorePlus, group: str, preset: str +) -> Iterable[DevicePropertySetting]: + for device, prop, value in core.getConfigData(group, preset): + prop_model = DevicePropertySetting( + device=_get_device(core, device), + value=value, + **get_property_info(core, device, prop), + ) + yield prop_model + + +def get_property_info(core: CMMCorePlus, device_label: str, property_name: str) -> dict: + """Get information about a property of a device. + + Doe *NOT* include the current value of the property. + """ + max_len = 0 + limits = None + if core.isPropertySequenceable(device_label, property_name): + max_len = core.getPropertySequenceMaxLength(device_label, property_name) + if core.hasPropertyLimits(device_label, property_name): + limits = ( + core.getPropertyLowerLimit(device_label, property_name), + core.getPropertyUpperLimit(device_label, property_name), + ) + + return { + "property_name": property_name, + "property_type": core.getPropertyType(device_label, property_name), + "is_read_only": core.isPropertyReadOnly(device_label, property_name), + "is_pre_init": core.isPropertyPreInit(device_label, property_name), + "allowed_values": core.getAllowedPropertyValues(device_label, property_name), + "sequence_max_length": max_len, + "limits": limits, + } + + +def get_loaded_devices(core: CMMCorePlus) -> Iterable[Device]: + """Get the model for all devices.""" + for label in core.getLoadedDevices(): + dev = Device( + label=label, + name=core.getDeviceName(label), + description=core.getDeviceDescription(label), + library=core.getDeviceLibrary(label), + type=core.getDeviceType(label), + ) + props = [] + for prop in core.getDevicePropertyNames(label): + prop_info = get_property_info(core, label, prop) + props.append(DevicePropertySetting(device=dev, **prop_info)) + dev.properties = tuple(props) + yield dev + + +def get_available_devices( + core: CMMCorePlus, *, exclude: Container[tuple[str, str]] = () +) -> Iterable[Device]: + """Get all available devices, not just the loaded ones. + + Use `exclude` to filter out devices that should not be included (e.g. device for + which you already have information from `get_loaded_devices()`): + >>> from pymmcore_plus import CMMCorePlus + >>> core = CMMCorePlus() + >>> loaded = get_loaded_devices(core) + >>> available = get_available_devices(core, exclude={dev.key() for dev in loaded}) + """ + for library in core.getDeviceAdapterNames(): + dev_names = core.getAvailableDevices(library) + types = core.getAvailableDeviceTypes(library) + descriptions = core.getAvailableDeviceDescriptions(library) + for dev_name, description, dev_type in zip(dev_names, descriptions, types): + if (library, dev_name) not in exclude: + yield Device( + name=dev_name, + library=library, + description=description, + type=DeviceType(dev_type), + ) diff --git a/src/pymmcore_widgets/_models/_py_config_model.py b/src/pymmcore_widgets/_models/_py_config_model.py new file mode 100644 index 000000000..7743e71b2 --- /dev/null +++ b/src/pymmcore_widgets/_models/_py_config_model.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar, Literal + +from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator +from pymmcore_plus import DeviceType, Keyword, PropertyType +from typing_extensions import TypeAlias + +from pymmcore_widgets._icons import StandardIcon + +if TYPE_CHECKING: + from collections.abc import Hashable + +AffineTuple: TypeAlias = tuple[float, float, float, float, float, float] + + +class _BaseModel(BaseModel): + """Base model for configuration presets.""" + + model_config: ClassVar = ConfigDict( + extra="forbid", + validate_assignment=True, + ) + + +class Device(_BaseModel): + """A device in the system.""" + + label: str = "" # if empty, the device is not loaded + + name: str = Field(default="", frozen=True) + library: str = Field(default="", frozen=True) + description: str = Field(default="", frozen=True) + type: DeviceType = Field(default=DeviceType.Unknown, frozen=True) + + properties: tuple[DevicePropertySetting, ...] = Field(default_factory=tuple) + + @property + def children(self) -> tuple[DevicePropertySetting, ...]: + """Return the properties of the device.""" + return self.properties + + @property + def is_loaded(self) -> bool: + """Return True if the device is loaded.""" + return bool(self.label) + + @property + def iconify_key(self) -> str | None: + """Return an iconify key for the device type.""" + from pymmcore_widgets._icons import DEVICE_TYPE_ICON + + return DEVICE_TYPE_ICON.get(self.type, None) + + def key(self) -> Hashable: + """Return a unique key for the device.""" + return (self.library, self.name) + + @model_validator(mode="before") + @classmethod + def _validate_input(cls, values: Any) -> Any: + """Validate the input values.""" + if isinstance(values, str): + return {"label": values} + return values + + +class DevicePropertySetting(_BaseModel): + """One property on a device.""" + + device: Device = Field(..., repr=False, exclude=True) + property_name: str + value: str = "" + + is_read_only: bool = Field(default=False, frozen=True) + is_pre_init: bool = Field(default=False, frozen=True) + allowed_values: tuple[str, ...] = Field(default_factory=tuple, frozen=True) + limits: tuple[float, float] | None = Field(default=None, frozen=True) + property_type: PropertyType = Field(default=PropertyType.Undef, frozen=True) + sequence_max_length: int = Field(default=0, frozen=True) + + parent: ConfigPreset | None = Field(default=None, exclude=True, repr=False) + + @computed_field # type: ignore[prop-decorator] + @property + def device_label(self) -> str: + """Return the label of the device.""" + return self.device.label + + @property + def is_advanced(self) -> bool: + """Return True if the property is less likely to be needed usually.""" + if self.device.type == DeviceType.State and self.property_name == Keyword.State: + return True + return False + + def key(self) -> tuple[str, str]: + """Return a unique key for the Property.""" + return (self.device_label, self.property_name) + + def as_tuple(self) -> tuple[str, str, str]: + """Return the property as a tuple.""" + return (self.device_label, self.property_name, self.value) + + @property + def iconify_key(self) -> StandardIcon | None: + """Return an iconify key for the device type.""" + if self.is_read_only: + return StandardIcon.READ_ONLY + elif self.is_pre_init: + return StandardIcon.PRE_INIT + return None + + @model_validator(mode="before") + @classmethod + def _validate_input(cls, values: Any) -> Any: + """Validate the input values.""" + if isinstance(values, (list, tuple)): + if len(values) == 3: + return { + "device_label": values[0], + "property_name": values[1], + "value": values[2], + } + return values + + def display_name(self) -> str: + """Return a display name for the property.""" + return f"{self.device_label}-{self.property_name}" + + def __eq__(self, other: Any) -> bool: + # deal with recursive equality checks + if not isinstance(other, DevicePropertySetting): + return False + return ( + self.device_label == other.device_label + and self.property_name == other.property_name + and self.value == other.value + and self.is_read_only == other.is_read_only + and self.is_pre_init == other.is_pre_init + and self.allowed_values == other.allowed_values + and self.limits == other.limits + and self.property_type == other.property_type + and self.sequence_max_length == other.sequence_max_length + ) + + +class ConfigPreset(_BaseModel): + """Set of settings in a ConfigGroup.""" + + name: str + settings: list[DevicePropertySetting] = Field(default_factory=list) + + parent: ConfigGroup | None = Field(default=None, exclude=True, repr=False) + + def __eq__(self, value: object) -> bool: + if not isinstance(value, ConfigPreset): + return False + return self.name == value.name and self.settings == value.settings + + @property + def children(self) -> tuple[DevicePropertySetting, ...]: + """Return the settings in the preset.""" + return tuple(self.settings) + + @property + def is_system_startup(self) -> bool: + """Return True if the preset is the system startup preset.""" + return ( + self.name.lower() == "startup" + and self.parent is not None + and self.parent.is_system_group + ) + + @property + def is_system_shutdown(self) -> bool: + """Return True if the preset is the system shutdown preset.""" + return ( + self.name.lower() == "shutdown" + and self.parent is not None + and self.parent.is_system_group + ) + + +class ConfigGroup(_BaseModel): + """A group of ConfigPresets.""" + + name: str + presets: dict[str, ConfigPreset] = Field(default_factory=dict) + + is_channel_group: bool = False + + @property + def is_system_group(self) -> bool: + """Return True if the group is a system group.""" + return self.name.lower() == "system" + + @property + def children(self) -> tuple[ConfigPreset, ...]: + """Return the presets in the group.""" + return tuple(self.presets.values()) + + +class PixelSizePreset(ConfigPreset): + """PixelSizePreset model.""" + + pixel_size_um: float = 0.0 + affine: AffineTuple = (1.0, 0.0, 0.0, 0.0, 1.0, 0.0) + dxdz: float = 0.0 + dydz: float = 0.0 + optimalz_um: float = 0.0 + + +class PixelSizeConfigs(ConfigGroup): + """Model of the pixel size group.""" + + name: str = "PixelSizeGroup" + presets: dict[str, PixelSizePreset] = Field(default_factory=dict) # type: ignore[assignment] + + is_channel_group: Literal[False] = Field(default=False, frozen=True) + + +DevicePropertySetting.model_rebuild() +ConfigPreset.model_rebuild() +ConfigGroup.model_rebuild() +PixelSizePreset.model_rebuild() +PixelSizeConfigs.model_rebuild() diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py similarity index 58% rename from src/pymmcore_widgets/config_presets/_qmodel/_config_model.py rename to src/pymmcore_widgets/_models/_q_config_model.py index 068011d67..72aa10bfd 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -1,15 +1,20 @@ from __future__ import annotations +import warnings from copy import deepcopy from enum import IntEnum -from typing import TYPE_CHECKING, Any, cast, overload +from typing import TYPE_CHECKING, Any, cast -from pymmcore_plus.model import ConfigGroup, ConfigPreset, Setting -from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt +from qtpy.QtCore import QModelIndex, Qt from qtpy.QtGui import QFont, QIcon -from qtpy.QtWidgets import QMessageBox +from qtpy.QtWidgets import QMessageBox, QWidget +from superqt import QIconifyIcon -from pymmcore_widgets._icons import get_device_icon +from pymmcore_widgets._icons import StandardIcon + +from ._base_tree_model import _BaseTreeModel, _Node +from ._core_functions import get_config_groups +from ._py_config_model import ConfigGroup, ConfigPreset, DevicePropertySetting if TYPE_CHECKING: from collections.abc import Iterable @@ -28,73 +33,15 @@ class Col(IntEnum): Value = 2 -class _Node: - """Generic tree node that wraps a ConfigGroup, ConfigPreset, or Setting.""" - - @classmethod - def create( - cls, - payload: ConfigGroup | ConfigPreset | Setting, - parent: _Node | None = None, - recursive: bool = True, - ) -> Self: - """Create a new _Node with the given name and payload.""" - if isinstance(payload, Setting): - name = f"{payload.device_name}-{payload.property_name}" - else: - name = payload.name - - node = cls(name, payload, parent) - if recursive: - if isinstance(payload, ConfigGroup): - for p in payload.presets.values(): - node.children.append(_Node.create(p, node)) - elif isinstance(payload, ConfigPreset): - for s in payload.settings: - node.children.append(_Node.create(s, node)) - return node - - def __init__( - self, - name: str, - payload: ConfigGroup | ConfigPreset | Setting | None = None, - parent: _Node | None = None, - ) -> None: - self.name = name - self.payload = payload - self.parent = parent - self.children: list[_Node] = [] - - # convenience ------------------------------------------------------------ - - def row_in_parent(self) -> int: - return -1 if self.parent is None else self.parent.children.index(self) - - # type helpers ----------------------------------------------------------- - - @property - def is_group(self) -> bool: - return isinstance(self.payload, ConfigGroup) - - @property - def is_preset(self) -> bool: - return isinstance(self.payload, ConfigPreset) - - @property - def is_setting(self) -> bool: - return isinstance(self.payload, Setting) - - -class QConfigGroupsModel(QAbstractItemModel): +class QConfigGroupsModel(_BaseTreeModel): """Three-level model: root → groups → presets → settings.""" @classmethod def create_from_core(cls, core: CMMCorePlus) -> Self: - return cls(ConfigGroup.all_config_groups(core).values()) + return cls(get_config_groups(core)) def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: super().__init__() - self._root = _Node("", None) if groups: self.set_groups(groups) @@ -102,50 +49,10 @@ def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: # Required Qt model overrides # ------------------------------------------------------------------ - # structure helpers ------------------------------------------------------- - - def rowCount(self, parent: QModelIndex | None = None) -> int: - # Only column 0 should have children in tree models - if parent is not None and parent.isValid() and parent.column() != 0: - return 0 - return len(self._node_from_index(parent).children) - def columnCount(self, _parent: QModelIndex | None = None) -> int: # In most subclasses, the number of columns is independent of the parent. return len(Col) - def index( - self, row: int, column: int = 0, parent: QModelIndex | None = None - ) -> QModelIndex: - """Return the index of the item specified by row, column and parent index.""" - parent_node = self._node_from_index(parent) - if 0 <= row < len(parent_node.children): - return self.createIndex(row, column, parent_node.children[row]) - return QModelIndex() # pragma: no cover - - @overload - def parent(self, child: QModelIndex) -> QModelIndex: ... - @overload - def parent(self) -> QObject | None: ... - def parent(self, child: QModelIndex | None = None) -> QModelIndex | QObject | None: - """Return the parent of the model item with the given index. - - If the item has no parent, an invalid QModelIndex is returned. - """ - if child is None: # pragma: no cover - return None - node = self._node_from_index(child) - if ( - node is self._root - or not (parent_node := node.parent) - or parent_node is self._root - ): - return QModelIndex() - - # A common convention used in models that expose tree data structures is that - # only items in the first column have children. - return self.createIndex(parent_node.row_in_parent(), 0, parent_node) - # data & editing ---------------------------------------------------------- def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: @@ -158,35 +65,46 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A if role == Qt.ItemDataRole.UserRole: return node.payload - if role == Qt.ItemDataRole.FontRole and index.column() == Col.Item: + col = index.column() + if role == Qt.ItemDataRole.FontRole and col == Col.Item: f = QFont() if node.is_group: f.setBold(True) return f - if role == Qt.ItemDataRole.DecorationRole and index.column() == Col.Item: + if role == Qt.ItemDataRole.DecorationRole and col == Col.Item: if node.is_group: - return QIcon.fromTheme("folder") + grp = cast("ConfigGroup", node.payload) + if grp.is_channel_group: + return StandardIcon.CHANNEL_GROUP.icon().pixmap(16, 16) + if grp.is_system_group: + return StandardIcon.SYSTEM_GROUP.icon().pixmap(16, 16) + return StandardIcon.CONFIG_GROUP.icon().pixmap(16, 16) if node.is_preset: - return QIcon.fromTheme("document") + preset = cast("ConfigPreset", node.payload) + if preset.is_system_startup: + return StandardIcon.STARTUP.icon().pixmap(16, 16) + if preset.is_system_shutdown: + return StandardIcon.SHUTDOWN.icon().pixmap(16, 16) + return StandardIcon.CONFIG_PRESET.icon().pixmap(16, 16) if node.is_setting: - setting = cast("Setting", node.payload) - if icon := get_device_icon(setting.device_name, color="gray"): - return icon.pixmap(16, 16) + setting = cast("DevicePropertySetting", node.payload) + if icon_key := setting.iconify_key: + return QIconifyIcon(icon_key).pixmap(16, 16) return QIcon.fromTheme("emblem-system") # pragma: no cover if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): # settings: show Device, Property, Value if node.is_setting: - setting = cast("Setting", node.payload) - if index.column() == Col.Item: - return setting.device_name - if index.column() == Col.Property: + setting = cast("DevicePropertySetting", node.payload) + if col == Col.Item: + return setting.device_label + if col == Col.Property: return setting.property_name - if index.column() == Col.Value: - return setting.property_value + if col == Col.Value: + return setting.value # groups / presets: only show name - elif index.column() == Col.Item: + elif col == Col.Item: return node.name return None @@ -197,24 +115,27 @@ def setData( value: Any, role: int = Qt.ItemDataRole.EditRole, ) -> bool: + """Set data for the given index.""" node = self._node_from_index(index) if node is self._root or role != Qt.ItemDataRole.EditRole: return False # pragma: no cover if node.is_setting: if 0 > index.column() > 3: return False # pragma: no cover - dev, prop, val = list(cast("Setting", node.payload)) + dev, prop, val = cast("DevicePropertySetting", node.payload).as_tuple() # update node in place # FIXME ... this is hacky args = [dev, prop, val] args[index.column()] = str(value) node.name = f"{args[0]}-{args[1]}" - node.payload = new_setting = Setting(*args) + node.payload = new_setting = DevicePropertySetting( + device=args[0], property_name=args[1], value=args[2] + ) # also update the parent preset.settings list reference parent_preset = cast("ConfigPreset", node.parent.payload) # type: ignore for i, s in enumerate(parent_preset.settings): - if s[0:2] == (dev, prop): + if s.as_tuple()[0:2] == (dev, prop): parent_preset.settings[i] = new_setting break else: @@ -223,9 +144,6 @@ def setData( return False if self._name_exists(node.parent, new_name): - QMessageBox.warning( - None, "Duplicate name", f"Name '{new_name}' already exists." - ) return False node.name = new_name @@ -241,6 +159,9 @@ def flags(self, index: QModelIndex) -> Qt.ItemFlag: return Qt.ItemFlag.NoItemFlags fl = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled + if isinstance((grp := node.payload), ConfigGroup) and grp.is_system_group: + # system group name cannot be changed + return fl if node.is_setting and index.column() == Col.Value: fl |= Qt.ItemFlag.ItemIsEditable elif not node.is_setting and index.column() == Col.Item: @@ -292,12 +213,12 @@ def index_for_preset( # group-level ------------------------------------------------------------- - def add_group(self, base_name: str = "Group") -> QModelIndex: + def add_group(self, base_name: str = "New Group") -> QModelIndex: """Append a *new* empty group and return its QModelIndex.""" - name = self._unique_child_name(self._root, base_name) + name = self._unique_child_name(self._root, base_name, suffix="") group = ConfigGroup(name=name) row = self.rowCount() - if self.insertRows(row, 1, NULL_INDEX, _payloads=[group]): + if self.insertRows(row, 1, QModelIndex(), _payloads=[group]): return self.index(row, 0) return QModelIndex() # pragma: no cover @@ -306,26 +227,37 @@ def duplicate_group( ) -> QModelIndex: node = self._node_from_index(idx) if not isinstance((grp := node.payload), ConfigGroup): - raise ValueError("Reference index is not a ConfigGroup.") + warnings.warn("Reference index is not a ConfigGroup.", stacklevel=2) + return QModelIndex() new_grp = deepcopy(grp) - new_grp.name = new_name or self._unique_child_name(self._root, new_grp.name) + new_grp.is_channel_group = False # this never gets duplicated + + # Always ensure the name is unique, even if new_name is provided + if new_name is not None: + # Check if the provided name is unique, if not make it unique + new_grp.name = self._unique_child_name(self._root, new_name, suffix="") + else: + # Generate unique name based on original name + new_grp.name = self._unique_child_name(self._root, new_grp.name) + row = idx.row() + 1 - if self.insertRows(row, 1, NULL_INDEX, _payloads=[new_grp]): + if self.insertRows(row, 1, QModelIndex(), _payloads=[new_grp]): return self.index(row, 0) return QModelIndex() # pragma: no cover # preset-level ------------------------------------------------------------ def add_preset( - self, group_idx: QModelIndex, base_name: str = "Preset" + self, group_idx: QModelIndex, base_name: str = "New Preset" ) -> QModelIndex: group_node = self._node_from_index(group_idx) if not isinstance(group_node.payload, ConfigGroup): - raise ValueError("Reference index is not a ConfigGroup.") + warnings.warn("Reference index is not a ConfigGroup.", stacklevel=2) + return QModelIndex() - name = self._unique_child_name(group_node, base_name) - preset = ConfigPreset(name) + name = self._unique_child_name(group_node, base_name, suffix="") + preset = ConfigPreset(name=name, parent=group_node.payload) row = len(group_node.children) if self.insertRows(row, 1, group_idx, _payloads=[preset]): return self.index(row, 0, group_idx) @@ -336,16 +268,66 @@ def duplicate_preset( ) -> QModelIndex: pre_node = self._node_from_index(preset_index) if not isinstance((pre := pre_node.payload), ConfigPreset): - raise ValueError("Reference index is not a ConfigPreset.") + warnings.warn("Reference index is not a ConfigPreset.", stacklevel=2) + return QModelIndex() pre_copy = deepcopy(pre) group_idx = preset_index.parent() group_node = self._node_from_index(group_idx) - pre_copy.name = new_name or self._unique_child_name(group_node, pre_copy.name) + + # Always ensure the name is unique, even if new_name is provided + if new_name is not None: + # Check if the provided name is unique, if not make it unique + pre_copy.name = self._unique_child_name(group_node, new_name, suffix="") + else: + # Generate unique name based on original name + pre_copy.name = self._unique_child_name(group_node, pre_copy.name) + row = preset_index.row() + 1 if self.insertRows(row, 1, group_idx, _payloads=[pre_copy]): return self.index(row, 0, group_idx) return QModelIndex() # pragma: no cover + return QModelIndex() # pragma: no cover + + def set_channel_group(self, group_idx: QModelIndex | None) -> None: + """Set the given group as the channel group. + + If *group_idx* is None or invalid, unset the current channel group. + """ + changed = False + if group_idx is None or not group_idx.isValid(): + # unset existing channel group + for group_node in self._root.children: + if isinstance((grp := group_node.payload), ConfigGroup): + if grp.is_channel_group: + changed = True + grp.is_channel_group = False + else: + group_node = self._node_from_index(group_idx) + if not isinstance( + (grp := group_node.payload), ConfigGroup + ): # pragma: no cover + warnings.warn("Reference index is not a ConfigGroup.", stacklevel=2) + return + if grp.is_channel_group: + return # no change + + grp.is_channel_group = True + # unset all other groups + for sibling in group_node.siblings: + if isinstance((sibling_grp := sibling.payload), ConfigGroup): + if sibling_grp.is_channel_group: + changed = True + sibling_grp.is_channel_group = False + + changed = True + + if changed: + self.dataChanged.emit( + self.index(0, 0), + self.index(self.rowCount() - 1, 0), + [Qt.ItemDataRole.DecorationRole], + ) # generic remove ---------------------------------------------------------- @@ -375,27 +357,78 @@ def removeRows( if isinstance((p := n.payload), ConfigPreset) } elif isinstance((preset := parent_node.payload), ConfigPreset): - preset.settings = [cast("Setting", n.payload) for n in parent_node.children] + preset.settings = [ + cast("DevicePropertySetting", n.payload) for n in parent_node.children + ] self.endRemoveRows() return True - def remove(self, idx: QModelIndex) -> None: + # TODO: probably remove the QWidget logic from here + def remove( + self, + idx: QModelIndex, + *, + ask_confirmation: bool = False, + parent: QWidget | None = None, + ) -> None: if idx.isValid(): + if ask_confirmation: + item_name = idx.data(Qt.ItemDataRole.DisplayRole) + item_type = type(idx.data(Qt.ItemDataRole.UserRole)) + type_name = item_type.__name__.replace(("Config"), "Config ") + msg = QMessageBox.question( + parent, + "Confirm Deletion", + f"Are you sure you want to delete {type_name} {item_name!r}?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.Yes, + ) + if msg != QMessageBox.StandardButton.Yes: + return self.removeRows(idx.row(), 1, idx.parent()) + # ------------------------------------------------------------------ + # Validation helpers + # ------------------------------------------------------------------ + + def is_name_change_valid(self, index: QModelIndex, new_name: str) -> str | None: + """Validate a name change. + + Returns + ------- + str | None + error_message: Error message if invalid, None if valid + """ + node = self._node_from_index(index) + if node is self._root: + return "Cannot rename root node" + + new_name = new_name.strip() + if not new_name: + return "Name cannot be empty" + + if new_name == node.name: + return None # No change + + if self._name_exists(node.parent, new_name): + return f"Name '{new_name}' already exists" + + return None + # ------------------------------------------------------------------ # Public mutator helpers # ------------------------------------------------------------------ # TODO: feels like this should be replaced with a more canonical method... def update_preset_settings( - self, preset_idx: QModelIndex, settings: list[Setting] + self, preset_idx: QModelIndex, settings: list[DevicePropertySetting] ) -> None: """Replace settings for `preset_idx` and update the tree safely.""" preset_node = self._node_from_index(preset_idx) if not isinstance((preset := preset_node.payload), ConfigPreset): - raise ValueError("Reference index is not a ConfigPreset.") + warnings.warn("Reference index is not a ConfigPreset.", stacklevel=2) + return None # --- remove existing Setting rows --------------------------------- old_row_count = len(preset_node.children) @@ -410,6 +443,37 @@ def update_preset_settings( preset_node.children.append(_Node.create(s, preset_node)) self.endInsertRows() + def update_preset_properties( + self, preset_idx: QModelIndex, settings: Iterable[tuple[str, str]] + ) -> None: + """Update the preset to only include properties that match the given keys. + + Missing properties will be added as placeholder settings with empty values. + """ + preset_node = self._node_from_index(preset_idx) + if not isinstance((preset := preset_node.payload), ConfigPreset): + warnings.warn("Reference index is not a ConfigPreset.", stacklevel=2) + return + + setting_keys = set(settings) + + # Create a dict of existing settings keyed by (device, property_name) + existing_settings = {s.key(): s for s in preset.settings} + + # Build the final list of settings + final_settings = [] + + for key in setting_keys: + if key in existing_settings: + final_settings.append(existing_settings[key]) + else: + final_settings.append( + DevicePropertySetting(device=key[0], property_name=key[1]) + ) + + # Use the existing method to update the preset with the final settings + self.update_preset_settings(preset_idx, final_settings) + # name uniqueness --------------------------------------------------------- @staticmethod @@ -446,31 +510,20 @@ def get_groups(self) -> list[ConfigGroup]: """Return All ConfigGroups in the model.""" return deepcopy([cast("ConfigGroup", n.payload) for n in self._root.children]) - def _node_from_index(self, index: QModelIndex | None) -> _Node: - if ( - index - and index.isValid() - and isinstance((node := index.internalPointer()), _Node) - ): - # return the node if index is valid - return node - # otherwise return the root node - return self._root - # insertion --------------------------------------------------------------- # TODO: use this instead of _insert_node # def insertRows( - # self, row: int, count: int, parent: QModelIndex = NULL_INDEX - # ) -> bool: ... - + # self, row: int, count: int, parent: QModelIndex = QModelIndex() + # ) -> bool: def insertRows( self, row: int, count: int, parent: QModelIndex = NULL_INDEX, *, - _payloads: list[ConfigGroup | ConfigPreset | Setting] | None = None, + _payloads: list[ConfigGroup | ConfigPreset | DevicePropertySetting] + | None = None, ) -> bool: """Insert *count* rows at *row* under *parent*. @@ -489,10 +542,10 @@ def insertRows( if _payloads is None: _payloads = [] for _ in range(count): - if isinstance(parent_node.payload, ConfigGroup): + if isinstance((grp := parent_node.payload), ConfigGroup): # inserting a new ConfigPreset name = self._unique_child_name(parent_node, "Preset") - _payloads.append(ConfigPreset(name=name)) + _payloads.append(ConfigPreset(name=name, parent=grp)) elif isinstance(parent_node.payload, ConfigPreset): raise NotImplementedError( "Inserting a Setting is not supported in this context." @@ -510,10 +563,23 @@ def insertRows( name = self._unique_child_name(parent_node, "Group") _payloads.append(ConfigGroup(name=name)) + # IMPORTANT: Ensure name uniqueness when inserting provided payloads. + # This is critical for undo/redo operations which restore objects that + # may have names that conflict with current items due to operations + # that occurred after the original object was removed. self.beginInsertRows(parent, row, row + count - 1) # ---------- modify the tree ---------- for i, payload in enumerate(_payloads): + if isinstance(payload, (ConfigGroup, ConfigPreset)): + original_name = payload.name + if self._name_exists(parent_node, original_name): + # Only modify the name if there's actually a conflict + unique_name = self._unique_child_name( + parent_node, original_name, suffix="" + ) + payload.name = unique_name + parent_node.children.insert(row + i, _Node.create(payload, parent_node)) # ---------- keep dataclasses in sync ---------- @@ -526,7 +592,7 @@ def insertRows( elif isinstance((pre := parent_node.payload), ConfigPreset): settings = list(pre.settings) for i, payload in enumerate(_payloads): - settings.insert(row + i, cast("Setting", payload)) + settings.insert(row + i, cast("DevicePropertySetting", payload)) pre.settings = settings self.endInsertRows() diff --git a/src/pymmcore_widgets/_models/_q_device_prop_model.py b/src/pymmcore_widgets/_models/_q_device_prop_model.py new file mode 100644 index 000000000..bc37683ee --- /dev/null +++ b/src/pymmcore_widgets/_models/_q_device_prop_model.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +from contextlib import suppress +from copy import deepcopy +from typing import TYPE_CHECKING, Any, cast + +from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt +from qtpy.QtGui import QBrush, QFont, QIcon +from superqt import QIconifyIcon + +from ._base_tree_model import _BaseTreeModel, _Node +from ._core_functions import get_loaded_devices +from ._py_config_model import Device, DevicePropertySetting + +if TYPE_CHECKING: + from collections.abc import Iterable + + from pymmcore_plus import CMMCorePlus + from typing_extensions import Self + +NULL_INDEX = QModelIndex() + + +class QDevicePropertyModel(_BaseTreeModel): + """2-level model: devices -> properties.""" + + @classmethod + def create_from_core(cls, core: CMMCorePlus) -> Self: + return cls(get_loaded_devices(core)) + + def __init__(self, devices: Iterable[Device] | None = None) -> None: + super().__init__() + if devices: + self.set_devices(devices) + + # ------------------------------------------------------------------ + # Required Qt model overrides + # ------------------------------------------------------------------ + + def columnCount(self, _parent: QModelIndex | None = None) -> int: + return 2 + + # data & editing ---------------------------------------------------------- + + def _get_device_data(self, device: Device, col: int, role: int) -> Any: + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): + if col == 1: + return device.type.name + else: + return device.label or f"{device.library}::{device.name}" + elif role == Qt.ItemDataRole.DecorationRole: + if col == 0: + if icon := device.iconify_key: + return QIconifyIcon(icon, color="gray").pixmap(16, 16) + return QIcon.fromTheme("emblem-system") # pragma: no cover + + return None + + def _get_prop_data(self, prop: DevicePropertySetting, col: int, role: int) -> Any: + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): + if col == 1: + return prop.property_type.name + else: + return prop.property_name + elif role == Qt.ItemDataRole.DecorationRole: + if col == 0: + if icon := prop.iconify_key: + return QIconifyIcon(icon, color="gray").pixmap(16, 16) + + elif role == Qt.ItemDataRole.FontRole: + if prop.is_read_only: + font = QFont() + font.setItalic(True) + return font + elif role == Qt.ItemDataRole.ForegroundRole: + if prop.is_read_only or prop.is_pre_init: + return QBrush(Qt.GlobalColor.gray) + + return None + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + """Return the data stored for `role` for the item at `index`.""" + node = self._node_from_index(index) + if node is self._root: + return None + + col = index.column() + # Qt.ItemDataRole.UserRole => return the original python object + if role == Qt.ItemDataRole.UserRole: + return node.payload + + elif role == Qt.ItemDataRole.CheckStateRole: + if isinstance(setting := node.payload, DevicePropertySetting): + return node.check_state + + if isinstance(device := node.payload, Device): + return self._get_device_data(device, col, role) + elif isinstance(setting := node.payload, DevicePropertySetting): + return self._get_prop_data(setting, col, role) + return None + + def setData( + self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole + ) -> bool: + """Set the data for the item at `index` to `value` for `role`.""" + if not index.isValid(): + return False + + node = self._node_from_index(index) + if node is self._root: + return False + + if role == Qt.ItemDataRole.CheckStateRole: + if isinstance(setting := node.payload, DevicePropertySetting): + if not (setting.is_read_only or setting.is_pre_init): + node.check_state = Qt.CheckState(value) + self.dataChanged.emit(index, index, [role]) + return True + + return False + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + node = self._node_from_index(index) + if node is self._root: + return Qt.ItemFlag.NoItemFlags + + flags = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable + if index.column() == 0: + flags |= Qt.ItemFlag.ItemIsUserCheckable + return flags + + def set_devices(self, devices: Iterable[Device]) -> None: + """Clear model and set new devices.""" + self.beginResetModel() + self._root.children.clear() + for d in devices: + self._root.children.append(_Node.create(d, self._root)) + self.endResetModel() + + def get_devices(self) -> list[Device]: + """Return All Devices in the model.""" + return deepcopy([cast("Device", n.payload) for n in self._root.children]) + + def headerData( + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> Any: + if role == Qt.ItemDataRole.DisplayRole: + if orientation == Qt.Orientation.Horizontal: + return "Device/Property" if section == 0 else "Type" + elif orientation == Qt.Orientation.Vertical: + return str(section + 1) + return None + + +class DevicePropertyFlatProxy(QAbstractItemModel): + """Flatten `Device → Property` into rows: Device | Property.""" + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self._source_model: QAbstractItemModel | None = None + self._rows: list[tuple[int, int]] = [] + + def setSourceModel(self, source_model: QAbstractItemModel | None) -> None: + """Set the source model and connect to its signals.""" + # Disconnect from old model + if self._source_model is not None: + with suppress(RuntimeError): + self._source_model.modelReset.disconnect(self._rebuild_rows) + self._source_model.layoutChanged.disconnect(self._rebuild_rows) + self._source_model.rowsInserted.disconnect(self._rebuild_rows) + self._source_model.rowsRemoved.disconnect(self._rebuild_rows) + self._source_model.dataChanged.disconnect(self._on_source_data_changed) + + self._source_model = source_model + + # Connect to new model + if source_model is not None: + source_model.modelReset.connect(self._rebuild_rows) + source_model.layoutChanged.connect(self._rebuild_rows) + source_model.rowsInserted.connect(self._rebuild_rows) + source_model.rowsRemoved.connect(self._rebuild_rows) + source_model.dataChanged.connect(self._on_source_data_changed) + + self._rebuild_rows() + + def sourceModel(self) -> QAbstractItemModel | None: + """Return the source model.""" + return self._source_model + + def index( + self, row: int, column: int, parent: QModelIndex | None = None + ) -> QModelIndex: + if parent and parent.isValid(): + return QModelIndex() + if 0 <= row < len(self._rows) and 0 <= column < 2: + return self.createIndex(row, column) + return QModelIndex() + + def parent(self, index: QModelIndex) -> QModelIndex: + return QModelIndex() # Flat model has no hierarchy + + def rowCount(self, parent: QModelIndex | None = None) -> int: + if parent and parent.isValid(): + return 0 + return len(self._rows) + + def columnCount(self, parent: QModelIndex | None = None) -> int: + return 2 + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid(): + return None + + source_idx = self._mapped_index(index.row(), index.column()) + return source_idx.data(role) if source_idx.isValid() else None + + def setData( + self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole + ) -> bool: + if not index.isValid() or not self._source_model: + return False + + source_idx = self._mapped_index(index.row(), index.column()) + if source_idx.isValid(): + return bool(self._source_model.setData(source_idx, value, role)) + return False + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + if not index.isValid() or not self._source_model: + return Qt.ItemFlag.NoItemFlags + + source_idx = self._mapped_index(index.row(), index.column()) + if not source_idx.isValid(): + return Qt.ItemFlag.NoItemFlags + + flags = self._source_model.flags(source_idx) + if index.column() == 1: + flags |= Qt.ItemFlag.ItemIsUserCheckable + return flags + + def headerData( + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> Any: + if role == Qt.ItemDataRole.DisplayRole: + if orientation == Qt.Orientation.Horizontal: + return "Device" if section == 0 else "Property" + elif orientation == Qt.Orientation.Vertical: + return str(section + 1) + elif role == Qt.ItemDataRole.FontRole: + if orientation == Qt.Orientation.Horizontal: + font = QFont() + font.setBold(True) + return font + return None + + def sort( + self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder + ) -> None: + if column not in (0, 1) or not (sm := self._source_model): + return + + def _key(x: tuple[int, int]) -> Any: + par = sm.index(x[0], 0) if column == 1 else NULL_INDEX + return sm.index(x[column], 0, par).data() or "" + + self.layoutAboutToBeChanged.emit() + self._rows.sort(key=_key, reverse=order == Qt.SortOrder.DescendingOrder) + self.layoutChanged.emit() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _mapped_index(self, flat_row: int, column: int) -> QModelIndex: + """Return the corresponding source QModelIndex for a given flat row/column. + + Parameters + ---------- + flat_row : int + Row in the *flattened* proxy model. + column : int + Column in the *flattened* proxy model (0=device, 1=property). + + Returns + ------- + QModelIndex + The matching index in the source model, or an invalid index + when the mapping is impossible. + """ + if ( + self._source_model is None + or column not in (0, 1) + or flat_row < 0 + or flat_row >= len(self._rows) + ): + return QModelIndex() + + drow, prow = self._rows[flat_row] + device_idx = self._source_model.index(drow, 0) + if column == 0: + return device_idx + + # column 1 - property + if device_idx.isValid(): + return self._source_model.index(prow, 0, device_idx) + return QModelIndex() + + def _rebuild_rows(self) -> None: + """Rebuild the flattened row structure.""" + self.beginResetModel() + self._rows.clear() + + if self._source_model is not None: + for drow in range(self._source_model.rowCount()): + device_idx = self._source_model.index(drow, 0) + if device_idx.isValid(): + for prow in range(self._source_model.rowCount(device_idx)): + prop_idx = self._source_model.index(prow, 0, device_idx) + if prop_idx.isValid(): + self._rows.append((drow, prow)) + + self.endResetModel() + + def _on_source_data_changed( + self, top_left: QModelIndex, bottom_right: QModelIndex, roles: list[int] + ) -> None: + """Handle dataChanged signal from source model and emit signals.""" + if not self._source_model: + return + + # Find which flat proxy rows correspond to changed indices in source model + changed_rows: set[int] = set() + + # Check if any of our flattened rows correspond to the changed indices + for flat_row, (drow, prow) in enumerate(self._rows): + # Check if this flat row corresponds to a changed device or property + device_idx = self._source_model.index(drow, 0) + if device_idx.isValid(): + prop_idx = self._source_model.index(prow, 0, device_idx) + + # Check if the changed range includes our device or property + if self._index_in_range( + device_idx, top_left, bottom_right + ) or self._index_in_range(prop_idx, top_left, bottom_right): + changed_rows.add(flat_row) + + # Emit dataChanged for all affected flat proxy rows + for flat_row in changed_rows: + top_left_flat = self.index(flat_row, 0) + bottom_right_flat = self.index(flat_row, 1) + if top_left_flat.isValid() and bottom_right_flat.isValid(): + self.dataChanged.emit(top_left_flat, bottom_right_flat, roles) + + def _index_in_range( + self, index: QModelIndex, top_left: QModelIndex, bottom_right: QModelIndex + ) -> bool: + """Check if an index falls within the given range.""" + if not index.isValid() or not top_left.isValid() or not bottom_right.isValid(): + return False + + # Check if parent matches + if index.parent() != top_left.parent(): + return False + + # Check if row and column are in range + return bool( + top_left.row() <= index.row() <= bottom_right.row() + and top_left.column() <= index.column() <= bottom_right.column() + ) diff --git a/src/pymmcore_widgets/config_presets/__init__.py b/src/pymmcore_widgets/config_presets/__init__.py index a1e1eaf63..912389ef5 100644 --- a/src/pymmcore_widgets/config_presets/__init__.py +++ b/src/pymmcore_widgets/config_presets/__init__.py @@ -3,7 +3,6 @@ from ._group_preset_widget._group_preset_table_widget import GroupPresetTableWidget from ._objectives_pixel_configuration_widget import ObjectivesPixelConfigurationWidget from ._pixel_configuration_widget import PixelConfigurationWidget -from ._qmodel._config_model import QConfigGroupsModel from ._views._config_groups_tree import ConfigGroupsTree from ._views._config_presets_table import ConfigPresetsTable @@ -13,5 +12,4 @@ "GroupPresetTableWidget", "ObjectivesPixelConfigurationWidget", "PixelConfigurationWidget", - "QConfigGroupsModel", ] diff --git a/src/pymmcore_widgets/config_presets/_qmodel/__init__.py b/src/pymmcore_widgets/config_presets/_qmodel/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py index 1aaa7e804..bf86c15d7 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py @@ -4,10 +4,9 @@ from qtpy.QtWidgets import QTreeView, QWidget -from pymmcore_widgets.config_presets._qmodel._config_model import QConfigGroupsModel -from pymmcore_widgets.config_presets._views._property_setting_delegate import ( - PropertySettingDelegate, -) +from pymmcore_widgets._models import QConfigGroupsModel + +from ._property_setting_delegate import PropertySettingDelegate if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py index 99cbd1128..7ffe4b541 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py @@ -1,12 +1,11 @@ from __future__ import annotations +import os from contextlib import suppress -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from pymmcore_plus.model import ConfigPreset, Setting from qtpy.QtCore import ( QAbstractItemModel, - QAbstractTableModel, QModelIndex, QSize, Qt, @@ -14,20 +13,21 @@ QTransposeProxyModel, ) from qtpy.QtWidgets import QTableView, QToolBar, QVBoxLayout, QWidget -from superqt import QIconifyIcon -from pymmcore_widgets._icons import get_device_icon -from pymmcore_widgets.config_presets._qmodel._config_model import QConfigGroupsModel +from pymmcore_widgets._icons import StandardIcon +from pymmcore_widgets._models import ConfigGroupPivotModel, QConfigGroupsModel from ._property_setting_delegate import PropertySettingDelegate if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus - from pymmcore_plus.model import ConfigPreset from PyQt6.QtGui import QAction + else: from qtpy.QtGui import QAction +NOT_TESTING = "PYTEST_VERSION" not in os.environ + class ConfigPresetsTableView(QTableView): """Plain QTableView for displaying configuration presets. @@ -43,14 +43,14 @@ def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.setItemDelegate(PropertySettingDelegate(self)) self._transpose_proxy: QTransposeProxyModel | None = None - self._pivot_model: _ConfigGroupPivotModel | None = None + self._pivot_model: ConfigGroupPivotModel | None = None def setModel(self, model: QAbstractItemModel | None) -> None: """Set the model for the table view.""" if isinstance(model, QConfigGroupsModel): - matrix = _ConfigGroupPivotModel() + matrix = ConfigGroupPivotModel() matrix.setSourceModel(model) - elif isinstance(model, _ConfigGroupPivotModel): # pragma: no cover + elif isinstance(model, ConfigGroupPivotModel): # pragma: no cover matrix = model else: # pragma: no cover raise TypeError( @@ -60,6 +60,11 @@ def setModel(self, model: QAbstractItemModel | None) -> None: self._pivot_model = matrix super().setModel(matrix) + + # Connect to model signals to ensure persistent editors are always maintained + matrix.modelReset.connect(self._ensure_persistent_editors) + matrix.dataChanged.connect(self._ensure_persistent_editors) + # this is a bit magical... but it looks better # will only happen once if not getattr(self, "_have_stretched_headers", False): @@ -72,11 +77,26 @@ def stretchHeaders(self) -> None: hh.setSectionResizeMode(col, hh.ResizeMode.Stretch) self._have_stretched_headers = True - def _get_pivot_model(self) -> _ConfigGroupPivotModel: + def _ensure_persistent_editors(self) -> None: + """Ensure persistent editors are open for all cells after model changes.""" + # Use a single-shot timer to avoid opening editors during model updates + QTimer.singleShot(0, self.openPersistentEditors) + + def openPersistentEditors(self) -> None: + """Open persistent editors for the given index.""" + """Override to open persistent editors for all items.""" + if model := self.model(): + for row in range(model.rowCount()): + for col in range(model.columnCount()): + idx = model.index(row, col) + if idx.isValid(): + self.openPersistentEditor(idx) + + def _get_pivot_model(self) -> ConfigGroupPivotModel: model = self.model() if isinstance(model, QTransposeProxyModel): model = model.sourceModel() - if not isinstance(model, _ConfigGroupPivotModel): # pragma: no cover + if not isinstance(model, ConfigGroupPivotModel): # pragma: no cover raise ValueError("Source model is not set. Call setSourceModel first.") return model @@ -91,19 +111,26 @@ def setGroup(self, group_name_or_index: str | QModelIndex) -> None: """Set the group for the pivot model.""" model = self._get_pivot_model() model.setGroup(group_name_or_index) + # Ensure persistent editors are reopened after group change + # (the model reset from setGroup will close them) + QTimer.singleShot(0, self.openPersistentEditors) def transpose(self) -> None: """Transpose the table view.""" pivot = self.model() - if isinstance(pivot, _ConfigGroupPivotModel): + if isinstance(pivot, ConfigGroupPivotModel): self._transpose_proxy = QTransposeProxyModel() self._transpose_proxy.setSourceModel(pivot) super().setModel(self._transpose_proxy) + # Ensure persistent editors are maintained after transposing + QTimer.singleShot(0, self.openPersistentEditors) elif isinstance(pivot, QTransposeProxyModel): # Already transposed, revert to original model if self._pivot_model: super().setModel(self._pivot_model) self._transpose_proxy = None + # Ensure persistent editors are maintained after un-transposing + QTimer.singleShot(0, self.openPersistentEditors) def isTransposed(self) -> bool: """Check if the table view is currently transposed.""" @@ -140,17 +167,15 @@ def __init__(self, parent: QWidget | None = None) -> None: tb.setIconSize(QSize(16, 16)) tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) if act := tb.addAction( - QIconifyIcon("carbon:transpose"), "Transpose", self.view.transpose + StandardIcon.TRANSPOSE.icon(), "Transpose", self.view.transpose ): act.setCheckable(True) - self.remove_action = QAction(QIconifyIcon("mdi:delete-outline"), "Remove") + self.remove_action = QAction(StandardIcon.DELETE.icon(), "Remove") tb.addAction(self.remove_action) self.remove_action.triggered.connect(self._on_remove_action) - self.duplicate_action = QAction( - QIconifyIcon("mdi:content-duplicate"), "Duplicate" - ) + self.duplicate_action = QAction(StandardIcon.COPY.icon(), "Duplicate") tb.addAction(self.duplicate_action) self.duplicate_action.triggered.connect(self._on_duplicate_action) @@ -175,200 +200,31 @@ def setGroup(self, group_name_or_index: str | QModelIndex) -> None: self.view.setGroup(group_name_or_index) def _on_remove_action(self) -> None: - if not self.view.isTransposed(): - source_idx = self._get_selected_preset_index() - self.view.sourceModel().remove(source_idx) - # TODO: handle transposed case + source_idx = self._get_selected_preset_index() + if not source_idx.isValid(): + return + + source_model = self.view.sourceModel() + source_model.remove(source_idx, ask_confirmation=NOT_TESTING) def _on_duplicate_action(self) -> None: if not self.view.isTransposed(): source_idx = self._get_selected_preset_index() - self.view.sourceModel().duplicate_preset(source_idx) + if not source_idx.isValid(): + return + + source_model = self.view.sourceModel() + source_model.duplicate_preset(source_idx) # TODO: handle transposed case def _get_selected_preset_index(self) -> QModelIndex: """Get the currently selected preset from the source model.""" + if self.view.isTransposed(): + return QModelIndex() # TODO + if sm := self.view.selectionModel(): if indices := sm.selectedColumns(): pivot_model = self.view._get_pivot_model() col = indices[0].column() return pivot_model.get_source_index_for_column(col) return QModelIndex() # pragma: no cover - - -# ----------------------------------------------------------------------------- - - -class _ConfigGroupPivotModel(QAbstractTableModel): - """Pivot a single ConfigGroup into rows=Device/Property, cols=Presets.""" - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self._src: QConfigGroupsModel | None = None - self._gidx: QModelIndex | None = None - self._presets: list[ConfigPreset] = [] - self._rows: list[tuple[str, str]] = [] # (device_name, property_name) - self._data: dict[tuple[int, int], Setting] = {} - - def sourceModel(self) -> QConfigGroupsModel | None: - """Return the source model.""" - return self._src - - def setSourceModel(self, src_model: QConfigGroupsModel) -> None: - """Set the source model and rebuild the matrix.""" - if not isinstance(src_model, QConfigGroupsModel): # pragma: no cover - raise TypeError("Source model must be an instance of QConfigGroupsModel.") - self._src = src_model - - # -> keep the pivot up-to-date whenever the tree model changes - src_model.modelReset.connect(self._rebuild) - src_model.rowsInserted.connect(self._rebuild) - src_model.rowsRemoved.connect(self._rebuild) - src_model.dataChanged.connect(self._rebuild) - - def setGroup(self, group_name_or_index: str | QModelIndex) -> None: - """Set the group index to pivot and rebuild the matrix.""" - if self._src is None: # pragma: no cover - raise ValueError("Source model is not set. Call setSourceModel first.") - if not isinstance(group_name_or_index, QModelIndex): - self._gidx = self._src.index_for_group(group_name_or_index) - else: - if not group_name_or_index.isValid(): # pragma: no cover - raise ValueError("Invalid QModelIndex provided for group selection.") - self._gidx = group_name_or_index - self._rebuild() - - def setData( - self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole - ) -> bool: - """Set data for a specific cell in the pivot table.""" - if ( - role != Qt.ItemDataRole.EditRole - or not index.isValid() - or self._src is None - or self._gidx is None - or (row := index.row()) >= len(self._rows) - or (col := index.column()) >= len(self._presets) - ): - return False # pragma: no cover - - # Get the preset and device/property for this cell - preset = self._presets[col] - dev_prop = self._rows[row] - - # Create or update the setting - # Update our local data - self._data[(row, col)] = setting = Setting(dev_prop[0], dev_prop[1], str(value)) - - # Update the preset's settings list - preset_settings = list(preset.settings) - - # Find existing setting or add new one - for i, (dev, prop, *_) in enumerate(preset_settings): - if (dev, prop) == dev_prop: - preset_settings[i] = setting - break - else: - preset_settings.append(setting) - - # Find the preset index in the source model and update it - preset_idx = self._src.index_for_preset(self._gidx, preset.name) - if preset_idx.isValid(): - self._src.update_preset_settings(preset_idx, preset_settings) - - # Emit dataChanged signal for the specific cell - self._src.dataChanged.emit(preset_idx, preset_idx, [role]) - return True - - # ---------------------------------------------------------------- build -- - - def _rebuild(self) -> None: # slot signature is flexible - if self._gidx is None: # nothing selected yet - return # pragma: no cover - self.beginResetModel() - - node = self._gidx.internalPointer() - self._presets = [child.payload for child in node.children] - keys = ((dev, prop) for p in self._presets for (dev, prop, *_) in p.settings) - self._rows = list(dict.fromkeys(keys, None)) # unique (device, prop) pairs - - self._data.clear() - for col, preset in enumerate(self._presets): - for row, (device, prop) in enumerate(self._rows): - for s in preset.settings: - if (s.device_name, s.property_name) == (device, prop): - self._data[(row, col)] = s - break - - self.endResetModel() - - # --------------------------------------------------------- Qt overrides -- - - def rowCount(self, parent: QModelIndex | None = None) -> int: - if parent is not None and parent.isValid(): - return 0 - return len(self._rows) - - def columnCount(self, parent: QModelIndex | None = None) -> int: - if parent is not None and parent.isValid(): - return 0 - return len(self._presets) - - def headerData( - self, - section: int, - orient: Qt.Orientation, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if role == Qt.ItemDataRole.DisplayRole: - if orient == Qt.Orientation.Horizontal: - return self._presets[section].name - return "-".join(self._rows[section]) - elif role == Qt.ItemDataRole.DecorationRole: - if orient == Qt.Orientation.Vertical: - try: - dev, _prop = self._rows[section] - except IndexError: # pragma: no cover - return None - if icon := get_device_icon(dev): - return icon.pixmap(QSize(16, 16)) - return None - - def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: - if not index.isValid(): # pragma: no cover - return None - - setting = self._data.get((index.row(), index.column())) - if setting is None: - return None - - if role == Qt.ItemDataRole.UserRole: - return setting - - if role in ( - Qt.ItemDataRole.DisplayRole, - Qt.ItemDataRole.EditRole, - ): - return setting.property_value if setting else None - return None - - # make editable - def flags(self, index: QModelIndex) -> Qt.ItemFlag: - if not index.isValid(): # pragma: no cover - return Qt.ItemFlag.NoItemFlags - return ( - Qt.ItemFlag.ItemIsEnabled - | Qt.ItemFlag.ItemIsSelectable - | Qt.ItemFlag.ItemIsEditable - ) - - def get_source_index_for_column(self, column: int) -> QModelIndex: - """Get the source index for a given column in the pivot model.""" - if self._src is None or self._gidx is None: # pragma: no cover - raise ValueError("Source model or group index is not set.") - if column < 0 or column >= len(self._presets): # pragma: no cover - raise IndexError("Column index out of range.") - - preset = self._presets[column] - preset_idx = self._src.index_for_preset(self._gidx, preset.name) - return preset_idx diff --git a/src/pymmcore_widgets/config_presets/_views/_property_setting_delegate.py b/src/pymmcore_widgets/config_presets/_views/_property_setting_delegate.py index 7e0bf25a5..30cbe47cf 100644 --- a/src/pymmcore_widgets/config_presets/_views/_property_setting_delegate.py +++ b/src/pymmcore_widgets/config_presets/_views/_property_setting_delegate.py @@ -1,9 +1,9 @@ from __future__ import annotations -from pymmcore_plus.model import Setting from qtpy.QtCore import QAbstractItemModel, QModelIndex, Qt from qtpy.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem, QWidget +from pymmcore_widgets._models import DevicePropertySetting from pymmcore_widgets.device_properties import PropertyWidget @@ -13,19 +13,27 @@ class PropertySettingDelegate(QStyledItemDelegate): def createEditor( self, parent: QWidget | None, option: QStyleOptionViewItem, index: QModelIndex ) -> QWidget | None: - if not isinstance((setting := index.data(Qt.ItemDataRole.UserRole)), Setting): + if not isinstance( + (setting := index.data(Qt.ItemDataRole.UserRole)), DevicePropertySetting + ): return super().createEditor(parent, option, index) # pragma: no cover - dev, prop, *_ = setting - widget = PropertyWidget(dev, prop, parent=parent, connect_core=False) - widget.setValue(setting.property_value) # avoids commitData warnings + widget = PropertyWidget( + setting.device_label, + setting.property_name, + parent=parent, + connect_core=False, + ) + widget.setValue(setting.value) # avoids commitData warnings widget.valueChanged.connect(lambda: self.commitData.emit(widget)) widget.setAutoFillBackground(True) return widget def setEditorData(self, editor: QWidget | None, index: QModelIndex) -> None: setting = index.data(Qt.ItemDataRole.UserRole) - if isinstance(setting, Setting) and isinstance(editor, PropertyWidget): - editor.setValue(setting.property_value) + if isinstance(setting, DevicePropertySetting) and isinstance( + editor, PropertyWidget + ): + editor.setValue(setting.value) else: # pragma: no cover super().setEditorData(editor, index) diff --git a/src/pymmcore_widgets/device_properties/_device_property_table.py b/src/pymmcore_widgets/device_properties/_device_property_table.py index 57ee3673c..6cac3b42d 100644 --- a/src/pymmcore_widgets/device_properties/_device_property_table.py +++ b/src/pymmcore_widgets/device_properties/_device_property_table.py @@ -9,7 +9,7 @@ from qtpy.QtWidgets import QAbstractScrollArea, QTableWidget, QTableWidgetItem, QWidget from superqt.iconify import QIconifyIcon -from pymmcore_widgets._icons import ICONS +from pymmcore_widgets._icons import DEVICE_TYPE_ICON from pymmcore_widgets._util import NoWheelTableWidget from ._property_widget import PropertyWidget @@ -129,7 +129,7 @@ def _rebuild_table(self) -> None: extra = " 🅿" if prop.isPreInit() else "" item = QTableWidgetItem(f"{prop.device}-{prop.name}{extra}") item.setData(self.PROP_ROLE, prop) - if icon_string := ICONS.get(prop.deviceType()): + if icon_string := DEVICE_TYPE_ICON.get(prop.deviceType()): item.setIcon(QIconifyIcon(icon_string, color="Gray")) self.setItem(i, 0, item) diff --git a/src/pymmcore_widgets/hcwizard/devices_page.py b/src/pymmcore_widgets/hcwizard/devices_page.py index d64afe5fa..8526edbef 100644 --- a/src/pymmcore_widgets/hcwizard/devices_page.py +++ b/src/pymmcore_widgets/hcwizard/devices_page.py @@ -25,7 +25,7 @@ from superqt.iconify import QIconifyIcon from superqt.utils import exceptions_as_dialog, signals_blocked -from pymmcore_widgets._icons import ICONS +from pymmcore_widgets._icons import DEVICE_TYPE_ICON from ._base_page import ConfigWizardPage from ._dev_setup_dialog import DeviceSetupDialog @@ -60,7 +60,7 @@ def rebuild(self, model: Microscope, errs: dict[str, str] | None = None) -> None self.clearContents() self.setRowCount(len(model.devices)) for i, device in enumerate(model.devices): - type_icon = ICONS.get(device.device_type, "") + type_icon = DEVICE_TYPE_ICON.get(device.device_type, "") wdg: QWidget if device.device_type == DeviceType.Hub: wdg = QPushButton(QIconifyIcon(type_icon, color="blue"), "") @@ -355,7 +355,7 @@ def rebuild_table(self) -> None: self.table.setItem(i, 1, item) # ----------- item = QTableWidgetItem(str(device.device_type)) - icon_string = ICONS.get(device.device_type, None) + icon_string = DEVICE_TYPE_ICON.get(device.device_type, None) if icon_string: item.setIcon(QIconifyIcon(icon_string, color="Gray")) if device.library_hub: @@ -370,7 +370,9 @@ def rebuild_table(self) -> None: _avail = {x.device_type for x in self._model.available_devices} avail = sorted(x for x in _avail if x != DeviceType.Any) for x in (DeviceType.Any, *avail): - self.dev_type.addItem(QIconifyIcon(ICONS.get(x, "")), str(x), x) + self.dev_type.addItem( + QIconifyIcon(DEVICE_TYPE_ICON.get(x, "")), str(x), x + ) if current in avail: self.dev_type.setCurrentText(str(current)) diff --git a/tests/test_config_groups_model.py b/tests/test_config_groups_model.py index 176c248ae..bf7a11689 100644 --- a/tests/test_config_groups_model.py +++ b/tests/test_config_groups_model.py @@ -1,18 +1,19 @@ from __future__ import annotations from typing import TYPE_CHECKING -from unittest.mock import patch import pytest from pymmcore_plus import CMMCorePlus -from pymmcore_plus.model import ConfigGroup, ConfigPreset, Setting from qtpy.QtCore import QModelIndex, Qt from qtpy.QtGui import QFont, QIcon, QPixmap -from qtpy.QtWidgets import QMessageBox -from pymmcore_widgets.config_presets import QConfigGroupsModel -from pymmcore_widgets.config_presets._views._config_presets_table import ( - _ConfigGroupPivotModel, +from pymmcore_widgets._models import ( + ConfigGroup, + ConfigGroupPivotModel, + ConfigPreset, + DevicePropertySetting, + QConfigGroupsModel, + get_config_groups, ) if TYPE_CHECKING: @@ -34,7 +35,7 @@ def test_model_initialization() -> None: # not using the fixture here, as we want to test the model creation directly core = CMMCorePlus() core.loadSystemConfiguration() - python_info = list(ConfigGroup.all_config_groups(core).values()) + python_info = list(get_config_groups(core)) model = QConfigGroupsModel(python_info) assert isinstance(model, QConfigGroupsModel) @@ -68,7 +69,7 @@ def test_model_basic_methods(model: QConfigGroupsModel) -> None: [ (Qt.ItemDataRole.DisplayRole, str), (Qt.ItemDataRole.EditRole, str), - (Qt.ItemDataRole.UserRole, (ConfigGroup, ConfigPreset, Setting)), + (Qt.ItemDataRole.UserRole, (ConfigGroup, ConfigPreset, DevicePropertySetting)), (Qt.ItemDataRole.FontRole, QFont), (Qt.ItemDataRole.DecorationRole, (QIcon, QPixmap)), ], @@ -120,9 +121,9 @@ def test_model_set_data(model: QConfigGroupsModel, qtbot: QtBot) -> None: assert group0.name == "NewGroupName" assert preset0.name == "NewPresetName" - assert setting0.device_name == "NewDevice" + assert setting0.device_label == "NewDevice" assert setting0.property_name == "NewProperty" - assert setting0.property_value == "NewSettingValue" + assert setting0.value == "NewSettingValue" # setting to the same value should not change the model current_name = grp0_index.data(Qt.ItemDataRole.EditRole) @@ -131,9 +132,8 @@ def test_model_set_data(model: QConfigGroupsModel, qtbot: QtBot) -> None: assert model.setData(grp0_index, "") is False # setting to a value that already exists should show a warning existing_name = model.index(1).data(Qt.ItemDataRole.EditRole) # next row down - with patch.object(QMessageBox, "warning") as mock_warning: - assert model.setData(grp0_index, existing_name) is False - mock_warning.assert_called_once() + # existing name should not modify the model + assert model.setData(grp0_index, existing_name) is False def test_index_queries(model: QConfigGroupsModel) -> None: @@ -175,7 +175,7 @@ def test_add_dupe_group(model: QConfigGroupsModel, qtbot: QtBot) -> None: grp0_index.data(Qt.ItemDataRole.DisplayRole) + " copy (1)", } - with pytest.raises(ValueError, match="Reference index is not a ConfigGroup."): + with pytest.warns(UserWarning, match="Reference index is not a ConfigGroup."): model.duplicate_group(QModelIndex()) @@ -193,10 +193,10 @@ def test_add_dupe_preset(model: QConfigGroupsModel, qtbot: QtBot) -> None: assert "New Preset" in new_presets assert preset0_index.data(Qt.ItemDataRole.DisplayRole) + " copy" in new_presets - with pytest.raises(ValueError, match="Reference index is not a ConfigPreset."): + with pytest.warns(UserWarning, match="Reference index is not a ConfigPreset."): model.duplicate_preset(QModelIndex()) - with pytest.raises(ValueError, match="Reference index is not a ConfigGroup."): + with pytest.warns(UserWarning, match="Reference index is not a ConfigGroup."): model.add_preset(QModelIndex(), "New Preset") @@ -215,15 +215,13 @@ def test_update_preset_settings(model: QConfigGroupsModel, qtbot: QtBot) -> None original_data = model.get_groups() preset0 = next(iter(original_data[0].presets.values())) assert len(preset0.settings) > 1 - assert preset0.settings[0].device_name != "NewDevice" + assert preset0.settings[0].device_label != "NewDevice" grp0_index = model.index(0, 0) preset0_index = model.index(0, 0, grp0_index) new_settings = [ - Setting( - device_name="NewDevice", - property_name="NewProperty", - property_value="NewValue", + DevicePropertySetting( + device="NewDevice", property_name="NewProperty", value="NewValue" ) ] model.update_preset_settings(preset0_index, new_settings) @@ -233,7 +231,7 @@ def test_update_preset_settings(model: QConfigGroupsModel, qtbot: QtBot) -> None assert len(preset0_new.settings) == 1 assert preset0_new.settings == new_settings - with pytest.raises(ValueError, match="Reference index is not a ConfigPreset."): + with pytest.warns(UserWarning, match="Reference index is not a ConfigPreset."): model.update_preset_settings(QModelIndex(), new_settings) @@ -244,7 +242,7 @@ def test_standard_item_model( def test_pivot_model(model: QConfigGroupsModel, qtmodeltester: ModelTester) -> None: - pivot = _ConfigGroupPivotModel() + pivot = ConfigGroupPivotModel() pivot.setSourceModel(model) pivot.setGroup("Channel") qtmodeltester.check(pivot) @@ -255,7 +253,7 @@ def test_pivot_model_two_way_sync( ) -> None: """Test _ConfigGroupPivotModel stays in sync with QConfigGroupsModel.""" # Create pivot model and set it up - pivot = _ConfigGroupPivotModel() + pivot = ConfigGroupPivotModel() pivot.setSourceModel(model) pivot.setGroup("Camera") # Camera group has 3 presets and 2 settings each qtmodeltester.check(pivot) @@ -283,8 +281,8 @@ def test_pivot_model_two_way_sync( # Add a setting to the new preset test_settings = [ - Setting("Camera", "Binning", "8"), - Setting("Camera", "BitDepth", "14"), + DevicePropertySetting(device="Camera", property_name="Binning", value="8"), + DevicePropertySetting(device="Camera", property_name="BitDepth", value="14"), ] model.update_preset_settings(new_preset_idx, test_settings) @@ -317,7 +315,7 @@ def test_pivot_model_two_way_sync( bitdepth_setting = next( s for s in lowres_preset.settings if s.property_name == "BitDepth" ) - assert bitdepth_setting.property_value == new_value + assert bitdepth_setting.value == new_value # Test 4: Removing presets from source updates pivot # Remove the TestPreset we added @@ -337,7 +335,9 @@ def test_pivot_model_two_way_sync( # Add a new setting that doesn't exist in other presets new_settings = [ *medres_preset.settings, - Setting("Camera", "NewProperty", "NewValue"), + DevicePropertySetting( + device="Camera", property_name="NewProperty", value="NewValue" + ), ] model.update_preset_settings(medres_preset_idx, new_settings) diff --git a/tests/test_config_groups_widgets.py b/tests/test_config_groups_widgets.py index ac09e70af..3bbaae003 100644 --- a/tests/test_config_groups_widgets.py +++ b/tests/test_config_groups_widgets.py @@ -3,12 +3,11 @@ from typing import TYPE_CHECKING from pymmcore_plus import CMMCorePlus -from pymmcore_plus.model import ConfigGroup from qtpy.QtCore import Qt from pymmcore_widgets import ConfigGroupsTree +from pymmcore_widgets._models import ConfigGroup, QConfigGroupsModel from pymmcore_widgets.config_presets import ConfigPresetsTable -from pymmcore_widgets.config_presets._qmodel._config_model import QConfigGroupsModel from pymmcore_widgets.config_presets._views._property_setting_delegate import ( PropertySettingDelegate, ) @@ -46,7 +45,7 @@ def test_config_groups_tree(qtbot: QtBot) -> None: assert model.data(setting_value) == "2" group0 = model.get_groups()[0] preset0 = next(iter(group0.presets.values())) - assert preset0.settings[0].property_value == "2" + assert preset0.settings[0].value == "2" def test_config_presets_table(qtbot: QtBot) -> None: From 63203e1cea5180c8c7a8e0fd058c22468898f487 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 16:12:57 -0400 Subject: [PATCH 02/14] update test and model --- src/pymmcore_widgets/_models/_q_config_model.py | 4 ++++ tests/test_config_groups_model.py | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/_models/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py index 72aa10bfd..5bdf5f6f9 100644 --- a/src/pymmcore_widgets/_models/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -144,6 +144,10 @@ def setData( return False if self._name_exists(node.parent, new_name): + warnings.warn( + f"Not adding duplicate name '{new_name}'. It already exists.", + stacklevel=2, + ) return False node.name = new_name diff --git a/tests/test_config_groups_model.py b/tests/test_config_groups_model.py index bf7a11689..5232b70b6 100644 --- a/tests/test_config_groups_model.py +++ b/tests/test_config_groups_model.py @@ -133,7 +133,12 @@ def test_model_set_data(model: QConfigGroupsModel, qtbot: QtBot) -> None: # setting to a value that already exists should show a warning existing_name = model.index(1).data(Qt.ItemDataRole.EditRole) # next row down # existing name should not modify the model - assert model.setData(grp0_index, existing_name) is False + with pytest.warns( + UserWarning, + match=f"Not adding duplicate name '{existing_name}'. It already exists.", + ): + # this should not change the model + assert model.setData(grp0_index, existing_name) is False def test_index_queries(model: QConfigGroupsModel) -> None: From 8147916cb282f618dbb7aa4cf31b8791c4c086c3 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 16:17:53 -0400 Subject: [PATCH 03/14] remove dev-prop model --- src/pymmcore_widgets/_models/__init__.py | 2 - .../_models/_q_device_prop_model.py | 375 ------------------ 2 files changed, 377 deletions(-) delete mode 100644 src/pymmcore_widgets/_models/_q_device_prop_model.py diff --git a/src/pymmcore_widgets/_models/__init__.py b/src/pymmcore_widgets/_models/__init__.py index 8c24ac8e7..17a39200e 100644 --- a/src/pymmcore_widgets/_models/__init__.py +++ b/src/pymmcore_widgets/_models/__init__.py @@ -16,7 +16,6 @@ PixelSizePreset, ) from ._q_config_model import QConfigGroupsModel -from ._q_device_prop_model import QDevicePropertyModel __all__ = [ "ConfigGroup", @@ -27,7 +26,6 @@ "PixelSizeConfigs", "PixelSizePreset", "QConfigGroupsModel", - "QDevicePropertyModel", "get_available_devices", "get_config_groups", "get_config_presets", diff --git a/src/pymmcore_widgets/_models/_q_device_prop_model.py b/src/pymmcore_widgets/_models/_q_device_prop_model.py deleted file mode 100644 index bc37683ee..000000000 --- a/src/pymmcore_widgets/_models/_q_device_prop_model.py +++ /dev/null @@ -1,375 +0,0 @@ -from __future__ import annotations - -from contextlib import suppress -from copy import deepcopy -from typing import TYPE_CHECKING, Any, cast - -from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt -from qtpy.QtGui import QBrush, QFont, QIcon -from superqt import QIconifyIcon - -from ._base_tree_model import _BaseTreeModel, _Node -from ._core_functions import get_loaded_devices -from ._py_config_model import Device, DevicePropertySetting - -if TYPE_CHECKING: - from collections.abc import Iterable - - from pymmcore_plus import CMMCorePlus - from typing_extensions import Self - -NULL_INDEX = QModelIndex() - - -class QDevicePropertyModel(_BaseTreeModel): - """2-level model: devices -> properties.""" - - @classmethod - def create_from_core(cls, core: CMMCorePlus) -> Self: - return cls(get_loaded_devices(core)) - - def __init__(self, devices: Iterable[Device] | None = None) -> None: - super().__init__() - if devices: - self.set_devices(devices) - - # ------------------------------------------------------------------ - # Required Qt model overrides - # ------------------------------------------------------------------ - - def columnCount(self, _parent: QModelIndex | None = None) -> int: - return 2 - - # data & editing ---------------------------------------------------------- - - def _get_device_data(self, device: Device, col: int, role: int) -> Any: - if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - if col == 1: - return device.type.name - else: - return device.label or f"{device.library}::{device.name}" - elif role == Qt.ItemDataRole.DecorationRole: - if col == 0: - if icon := device.iconify_key: - return QIconifyIcon(icon, color="gray").pixmap(16, 16) - return QIcon.fromTheme("emblem-system") # pragma: no cover - - return None - - def _get_prop_data(self, prop: DevicePropertySetting, col: int, role: int) -> Any: - if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - if col == 1: - return prop.property_type.name - else: - return prop.property_name - elif role == Qt.ItemDataRole.DecorationRole: - if col == 0: - if icon := prop.iconify_key: - return QIconifyIcon(icon, color="gray").pixmap(16, 16) - - elif role == Qt.ItemDataRole.FontRole: - if prop.is_read_only: - font = QFont() - font.setItalic(True) - return font - elif role == Qt.ItemDataRole.ForegroundRole: - if prop.is_read_only or prop.is_pre_init: - return QBrush(Qt.GlobalColor.gray) - - return None - - def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: - """Return the data stored for `role` for the item at `index`.""" - node = self._node_from_index(index) - if node is self._root: - return None - - col = index.column() - # Qt.ItemDataRole.UserRole => return the original python object - if role == Qt.ItemDataRole.UserRole: - return node.payload - - elif role == Qt.ItemDataRole.CheckStateRole: - if isinstance(setting := node.payload, DevicePropertySetting): - return node.check_state - - if isinstance(device := node.payload, Device): - return self._get_device_data(device, col, role) - elif isinstance(setting := node.payload, DevicePropertySetting): - return self._get_prop_data(setting, col, role) - return None - - def setData( - self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole - ) -> bool: - """Set the data for the item at `index` to `value` for `role`.""" - if not index.isValid(): - return False - - node = self._node_from_index(index) - if node is self._root: - return False - - if role == Qt.ItemDataRole.CheckStateRole: - if isinstance(setting := node.payload, DevicePropertySetting): - if not (setting.is_read_only or setting.is_pre_init): - node.check_state = Qt.CheckState(value) - self.dataChanged.emit(index, index, [role]) - return True - - return False - - def flags(self, index: QModelIndex) -> Qt.ItemFlag: - node = self._node_from_index(index) - if node is self._root: - return Qt.ItemFlag.NoItemFlags - - flags = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable - if index.column() == 0: - flags |= Qt.ItemFlag.ItemIsUserCheckable - return flags - - def set_devices(self, devices: Iterable[Device]) -> None: - """Clear model and set new devices.""" - self.beginResetModel() - self._root.children.clear() - for d in devices: - self._root.children.append(_Node.create(d, self._root)) - self.endResetModel() - - def get_devices(self) -> list[Device]: - """Return All Devices in the model.""" - return deepcopy([cast("Device", n.payload) for n in self._root.children]) - - def headerData( - self, - section: int, - orientation: Qt.Orientation, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if role == Qt.ItemDataRole.DisplayRole: - if orientation == Qt.Orientation.Horizontal: - return "Device/Property" if section == 0 else "Type" - elif orientation == Qt.Orientation.Vertical: - return str(section + 1) - return None - - -class DevicePropertyFlatProxy(QAbstractItemModel): - """Flatten `Device → Property` into rows: Device | Property.""" - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent) - self._source_model: QAbstractItemModel | None = None - self._rows: list[tuple[int, int]] = [] - - def setSourceModel(self, source_model: QAbstractItemModel | None) -> None: - """Set the source model and connect to its signals.""" - # Disconnect from old model - if self._source_model is not None: - with suppress(RuntimeError): - self._source_model.modelReset.disconnect(self._rebuild_rows) - self._source_model.layoutChanged.disconnect(self._rebuild_rows) - self._source_model.rowsInserted.disconnect(self._rebuild_rows) - self._source_model.rowsRemoved.disconnect(self._rebuild_rows) - self._source_model.dataChanged.disconnect(self._on_source_data_changed) - - self._source_model = source_model - - # Connect to new model - if source_model is not None: - source_model.modelReset.connect(self._rebuild_rows) - source_model.layoutChanged.connect(self._rebuild_rows) - source_model.rowsInserted.connect(self._rebuild_rows) - source_model.rowsRemoved.connect(self._rebuild_rows) - source_model.dataChanged.connect(self._on_source_data_changed) - - self._rebuild_rows() - - def sourceModel(self) -> QAbstractItemModel | None: - """Return the source model.""" - return self._source_model - - def index( - self, row: int, column: int, parent: QModelIndex | None = None - ) -> QModelIndex: - if parent and parent.isValid(): - return QModelIndex() - if 0 <= row < len(self._rows) and 0 <= column < 2: - return self.createIndex(row, column) - return QModelIndex() - - def parent(self, index: QModelIndex) -> QModelIndex: - return QModelIndex() # Flat model has no hierarchy - - def rowCount(self, parent: QModelIndex | None = None) -> int: - if parent and parent.isValid(): - return 0 - return len(self._rows) - - def columnCount(self, parent: QModelIndex | None = None) -> int: - return 2 - - def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: - if not index.isValid(): - return None - - source_idx = self._mapped_index(index.row(), index.column()) - return source_idx.data(role) if source_idx.isValid() else None - - def setData( - self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole - ) -> bool: - if not index.isValid() or not self._source_model: - return False - - source_idx = self._mapped_index(index.row(), index.column()) - if source_idx.isValid(): - return bool(self._source_model.setData(source_idx, value, role)) - return False - - def flags(self, index: QModelIndex) -> Qt.ItemFlag: - if not index.isValid() or not self._source_model: - return Qt.ItemFlag.NoItemFlags - - source_idx = self._mapped_index(index.row(), index.column()) - if not source_idx.isValid(): - return Qt.ItemFlag.NoItemFlags - - flags = self._source_model.flags(source_idx) - if index.column() == 1: - flags |= Qt.ItemFlag.ItemIsUserCheckable - return flags - - def headerData( - self, - section: int, - orientation: Qt.Orientation, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if role == Qt.ItemDataRole.DisplayRole: - if orientation == Qt.Orientation.Horizontal: - return "Device" if section == 0 else "Property" - elif orientation == Qt.Orientation.Vertical: - return str(section + 1) - elif role == Qt.ItemDataRole.FontRole: - if orientation == Qt.Orientation.Horizontal: - font = QFont() - font.setBold(True) - return font - return None - - def sort( - self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder - ) -> None: - if column not in (0, 1) or not (sm := self._source_model): - return - - def _key(x: tuple[int, int]) -> Any: - par = sm.index(x[0], 0) if column == 1 else NULL_INDEX - return sm.index(x[column], 0, par).data() or "" - - self.layoutAboutToBeChanged.emit() - self._rows.sort(key=_key, reverse=order == Qt.SortOrder.DescendingOrder) - self.layoutChanged.emit() - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - def _mapped_index(self, flat_row: int, column: int) -> QModelIndex: - """Return the corresponding source QModelIndex for a given flat row/column. - - Parameters - ---------- - flat_row : int - Row in the *flattened* proxy model. - column : int - Column in the *flattened* proxy model (0=device, 1=property). - - Returns - ------- - QModelIndex - The matching index in the source model, or an invalid index - when the mapping is impossible. - """ - if ( - self._source_model is None - or column not in (0, 1) - or flat_row < 0 - or flat_row >= len(self._rows) - ): - return QModelIndex() - - drow, prow = self._rows[flat_row] - device_idx = self._source_model.index(drow, 0) - if column == 0: - return device_idx - - # column 1 - property - if device_idx.isValid(): - return self._source_model.index(prow, 0, device_idx) - return QModelIndex() - - def _rebuild_rows(self) -> None: - """Rebuild the flattened row structure.""" - self.beginResetModel() - self._rows.clear() - - if self._source_model is not None: - for drow in range(self._source_model.rowCount()): - device_idx = self._source_model.index(drow, 0) - if device_idx.isValid(): - for prow in range(self._source_model.rowCount(device_idx)): - prop_idx = self._source_model.index(prow, 0, device_idx) - if prop_idx.isValid(): - self._rows.append((drow, prow)) - - self.endResetModel() - - def _on_source_data_changed( - self, top_left: QModelIndex, bottom_right: QModelIndex, roles: list[int] - ) -> None: - """Handle dataChanged signal from source model and emit signals.""" - if not self._source_model: - return - - # Find which flat proxy rows correspond to changed indices in source model - changed_rows: set[int] = set() - - # Check if any of our flattened rows correspond to the changed indices - for flat_row, (drow, prow) in enumerate(self._rows): - # Check if this flat row corresponds to a changed device or property - device_idx = self._source_model.index(drow, 0) - if device_idx.isValid(): - prop_idx = self._source_model.index(prow, 0, device_idx) - - # Check if the changed range includes our device or property - if self._index_in_range( - device_idx, top_left, bottom_right - ) or self._index_in_range(prop_idx, top_left, bottom_right): - changed_rows.add(flat_row) - - # Emit dataChanged for all affected flat proxy rows - for flat_row in changed_rows: - top_left_flat = self.index(flat_row, 0) - bottom_right_flat = self.index(flat_row, 1) - if top_left_flat.isValid() and bottom_right_flat.isValid(): - self.dataChanged.emit(top_left_flat, bottom_right_flat, roles) - - def _index_in_range( - self, index: QModelIndex, top_left: QModelIndex, bottom_right: QModelIndex - ) -> bool: - """Check if an index falls within the given range.""" - if not index.isValid() or not top_left.isValid() or not bottom_right.isValid(): - return False - - # Check if parent matches - if index.parent() != top_left.parent(): - return False - - # Check if row and column are in range - return bool( - top_left.row() <= index.row() <= bottom_right.row() - and top_left.column() <= index.column() <= bottom_right.column() - ) From 3cb0f71915218e6de86f4040e7ad772acb2cf4a6 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 16:23:17 -0400 Subject: [PATCH 04/14] coverage --- .../_models/_py_config_model.py | 23 +++---------------- .../_views/_config_presets_table.py | 1 - 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/src/pymmcore_widgets/_models/_py_config_model.py b/src/pymmcore_widgets/_models/_py_config_model.py index 7743e71b2..7d159e4ba 100644 --- a/src/pymmcore_widgets/_models/_py_config_model.py +++ b/src/pymmcore_widgets/_models/_py_config_model.py @@ -6,7 +6,7 @@ from pymmcore_plus import DeviceType, Keyword, PropertyType from typing_extensions import TypeAlias -from pymmcore_widgets._icons import StandardIcon +from pymmcore_widgets._icons import DEVICE_TYPE_ICON, StandardIcon if TYPE_CHECKING: from collections.abc import Hashable @@ -35,11 +35,6 @@ class Device(_BaseModel): properties: tuple[DevicePropertySetting, ...] = Field(default_factory=tuple) - @property - def children(self) -> tuple[DevicePropertySetting, ...]: - """Return the properties of the device.""" - return self.properties - @property def is_loaded(self) -> bool: """Return True if the device is loaded.""" @@ -48,8 +43,6 @@ def is_loaded(self) -> bool: @property def iconify_key(self) -> str | None: """Return an iconify key for the device type.""" - from pymmcore_widgets._icons import DEVICE_TYPE_ICON - return DEVICE_TYPE_ICON.get(self.type, None) def key(self) -> Hashable: @@ -130,7 +123,7 @@ def display_name(self) -> str: def __eq__(self, other: Any) -> bool: # deal with recursive equality checks - if not isinstance(other, DevicePropertySetting): + if not isinstance(other, DevicePropertySetting): # pragma: no cover return False return ( self.device_label == other.device_label @@ -154,15 +147,10 @@ class ConfigPreset(_BaseModel): parent: ConfigGroup | None = Field(default=None, exclude=True, repr=False) def __eq__(self, value: object) -> bool: - if not isinstance(value, ConfigPreset): + if not isinstance(value, ConfigPreset): # pragma: no cover return False return self.name == value.name and self.settings == value.settings - @property - def children(self) -> tuple[DevicePropertySetting, ...]: - """Return the settings in the preset.""" - return tuple(self.settings) - @property def is_system_startup(self) -> bool: """Return True if the preset is the system startup preset.""" @@ -195,11 +183,6 @@ def is_system_group(self) -> bool: """Return True if the group is a system group.""" return self.name.lower() == "system" - @property - def children(self) -> tuple[ConfigPreset, ...]: - """Return the presets in the group.""" - return tuple(self.presets.values()) - class PixelSizePreset(ConfigPreset): """PixelSizePreset model.""" diff --git a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py index 7ffe4b541..7cad77631 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py @@ -83,7 +83,6 @@ def _ensure_persistent_editors(self) -> None: QTimer.singleShot(0, self.openPersistentEditors) def openPersistentEditors(self) -> None: - """Open persistent editors for the given index.""" """Override to open persistent editors for all items.""" if model := self.model(): for row in range(model.rowCount()): From 6c2f84e56346274ce2fd637c0ca2e8b5368a3d74 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 16:42:44 -0400 Subject: [PATCH 05/14] add back dev dep --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cd5ec4c39..117e6b9ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,7 +85,7 @@ dev = [ "mypy>=1.15.0", "pdbpp>=0.11.6 ; sys_platform != 'win32'", "pre-commit-uv >=4.1.0", - # "pyqt6>=6.9.0", + "pyqt6>=6.9.0", "rich>=14.0.0", "ruff>=0.11.8", "types-shapely>=2.1.0.20250512", From 61103605803fcf5832be990fd0cff7a9f169831c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 16:47:55 -0400 Subject: [PATCH 06/14] fix py3.9 --- .../_models/_config_group_pivot_model.py | 8 ++++++-- src/pymmcore_widgets/_models/_py_config_model.py | 10 +++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/pymmcore_widgets/_models/_config_group_pivot_model.py b/src/pymmcore_widgets/_models/_config_group_pivot_model.py index 08b8a4507..52930f031 100644 --- a/src/pymmcore_widgets/_models/_config_group_pivot_model.py +++ b/src/pymmcore_widgets/_models/_config_group_pivot_model.py @@ -1,13 +1,17 @@ -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from qtpy.QtCore import QAbstractTableModel, QModelIndex, QSize, Qt -from qtpy.QtWidgets import QWidget from pymmcore_widgets._icons import get_device_icon from ._py_config_model import ConfigPreset, DevicePropertySetting from ._q_config_model import QConfigGroupsModel +if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + class ConfigGroupPivotModel(QAbstractTableModel): """Pivot a single ConfigGroup into rows=Device/Property, cols=Presets.""" diff --git a/src/pymmcore_widgets/_models/_py_config_model.py b/src/pymmcore_widgets/_models/_py_config_model.py index 7d159e4ba..0142389b0 100644 --- a/src/pymmcore_widgets/_models/_py_config_model.py +++ b/src/pymmcore_widgets/_models/_py_config_model.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, ClassVar, Literal +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator from pymmcore_plus import DeviceType, Keyword, PropertyType @@ -17,7 +17,7 @@ class _BaseModel(BaseModel): """Base model for configuration presets.""" - model_config: ClassVar = ConfigDict( + model_config: ClassVar[ConfigDict] = ConfigDict( extra="forbid", validate_assignment=True, ) @@ -68,11 +68,11 @@ class DevicePropertySetting(_BaseModel): is_read_only: bool = Field(default=False, frozen=True) is_pre_init: bool = Field(default=False, frozen=True) allowed_values: tuple[str, ...] = Field(default_factory=tuple, frozen=True) - limits: tuple[float, float] | None = Field(default=None, frozen=True) + limits: Optional[tuple[float, float]] = Field(default=None, frozen=True) # noqa property_type: PropertyType = Field(default=PropertyType.Undef, frozen=True) sequence_max_length: int = Field(default=0, frozen=True) - parent: ConfigPreset | None = Field(default=None, exclude=True, repr=False) + parent: Optional[ConfigPreset] = Field(default=None, exclude=True, repr=False) # noqa @computed_field # type: ignore[prop-decorator] @property @@ -144,7 +144,7 @@ class ConfigPreset(_BaseModel): name: str settings: list[DevicePropertySetting] = Field(default_factory=list) - parent: ConfigGroup | None = Field(default=None, exclude=True, repr=False) + parent: Optional[ConfigGroup] = Field(default=None, exclude=True, repr=False) # noqa def __eq__(self, value: object) -> bool: if not isinstance(value, ConfigPreset): # pragma: no cover From e8a48677c66fb417c34d511bf133fb637fbfc3eb Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 16:50:37 -0400 Subject: [PATCH 07/14] fix slot --- .../config_presets/_views/_config_presets_table.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py index 7cad77631..a4955f7af 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py @@ -84,12 +84,13 @@ def _ensure_persistent_editors(self) -> None: def openPersistentEditors(self) -> None: """Override to open persistent editors for all items.""" - if model := self.model(): - for row in range(model.rowCount()): - for col in range(model.columnCount()): - idx = model.index(row, col) - if idx.isValid(): - self.openPersistentEditor(idx) + with suppress(RuntimeError): # since this may be a slot + if model := self.model(): + for row in range(model.rowCount()): + for col in range(model.columnCount()): + idx = model.index(row, col) + if idx.isValid(): + self.openPersistentEditor(idx) def _get_pivot_model(self) -> ConfigGroupPivotModel: model = self.model() From 8f481b40c140af859a2ee14f6b8863076f0e2d0b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 16:54:20 -0400 Subject: [PATCH 08/14] pin pytest-qt --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 117e6b9ea..2e7a5bea9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,7 @@ Documentation = "https://pymmcore-plus.github.io/pymmcore-widgets" test = [ "pytest>=8.3.5", "pytest-cov>=6.1.1", - "pytest-qt>=4.4.0", + "pytest-qt==4.4.0", "pyyaml>=6.0.2", "zarr >=2.15,<3", "numcodecs >0.14.0,<0.16; python_version >= '3.13'", From 568e3e586f8fd0da6102d25f542d99c865c039d7 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 17:16:06 -0400 Subject: [PATCH 09/14] refactor: replace DEVICE_TYPE_ICON with StandardIcon for device type handling and add unit test for StandardIcon --- src/pymmcore_widgets/_icons.py | 85 ++++++++++++------- .../_models/_config_group_pivot_model.py | 6 +- .../_models/_py_config_model.py | 4 +- .../_models/_q_config_model.py | 4 +- .../_device_property_table.py | 7 +- src/pymmcore_widgets/hcwizard/devices_page.py | 18 ++-- tests/test_icons.py | 8 ++ 7 files changed, 78 insertions(+), 54 deletions(-) create mode 100644 tests/test_icons.py diff --git a/src/pymmcore_widgets/_icons.py b/src/pymmcore_widgets/_icons.py index 7e4bc7d36..49c4a62aa 100644 --- a/src/pymmcore_widgets/_icons.py +++ b/src/pymmcore_widgets/_icons.py @@ -5,26 +5,6 @@ from pymmcore_plus import CMMCorePlus, DeviceType from superqt import QIconifyIcon -DEVICE_TYPE_ICON: dict[DeviceType, str] = { - DeviceType.Any: "mdi:devices", - DeviceType.AutoFocus: "mdi:focus-auto", - DeviceType.Camera: "mdi:camera", - DeviceType.Core: "mdi:heart-cog-outline", - DeviceType.Galvo: "mdi:mirror-variant", - DeviceType.Generic: "mdi:dev-to", - DeviceType.Hub: "mdi:hubspot", - DeviceType.ImageProcessor: "mdi:image-auto-adjust", - DeviceType.Magnifier: "mdi:magnify", - DeviceType.Shutter: "mdi:camera-iris", - DeviceType.SignalIO: "fa6-solid:wave-square", - DeviceType.SLM: "mdi:view-comfy", - DeviceType.Stage: "mdi:arrow-up-down", - DeviceType.State: "mdi:state-machine", - DeviceType.Unknown: "mdi:question-mark-rhombus", - DeviceType.XYStage: "mdi:arrow-all", - DeviceType.Serial: "mdi:serial-port", -} - class StandardIcon(str, Enum): READ_ONLY = "fluent:edit-off-20-regular" @@ -48,23 +28,62 @@ class StandardIcon(str, Enum): UNDO = "mdi:undo" REDO = "mdi:redo" + DEVICE_ANY = "mdi:devices" + DEVICE_AUTOFOCUS = "mdi:focus-auto" + DEVICE_CAMERA = "mdi:camera" + DEVICE_CORE = "mdi:heart-cog-outline" + DEVICE_GALVO = "mdi:mirror-variant" + DEVICE_GENERIC = "mdi:dev-to" + DEVICE_HUB = "mdi:hubspot" + DEVICE_IMAGEPROCESSOR = "mdi:image-auto-adjust" + DEVICE_MAGNIFIER = "mdi:magnify" + DEVICE_SHUTTER = "mdi:camera-iris" + DEVICE_SIGNALIO = "fa6-solid:wave-square" + DEVICE_SLM = "mdi:view-comfy" + DEVICE_STAGE = "mdi:arrow-up-down" + DEVICE_STATE = "mdi:state-machine" + DEVICE_UNKNOWN = "mdi:question-mark-rhombus" + DEVICE_XYSTAGE = "mdi:arrow-all" + DEVICE_SERIAL = "mdi:serial-port" + def icon(self, color: str = "gray") -> QIconifyIcon: return QIconifyIcon(self.value, color=color) def __str__(self) -> str: return self.value + @classmethod + def for_device_type(cls, device_type: DeviceType | str) -> StandardIcon: + """Return an icon for a specific device type. + + If a string is provided, it will be resolved to a DeviceType using the + CMMCorePlus.instance. + """ + if isinstance(device_type, str): # device label + try: + device_type = CMMCorePlus.instance().getDeviceType(device_type) + except Exception: # pragma: no cover + device_type = DeviceType.Unknown -def get_device_icon( - device_type_or_name: DeviceType | str, color: str = "gray" -) -> QIconifyIcon | None: - if isinstance(device_type_or_name, str): - try: - device_type = CMMCorePlus.instance().getDeviceType(device_type_or_name) - except Exception: - device_type = DeviceType.Unknown - else: - device_type = device_type_or_name - if icon_string := DEVICE_TYPE_ICON.get(device_type): - return QIconifyIcon(icon_string, color=color) - return None + return _DEVICE_TYPE_MAP.get(device_type, StandardIcon.DEVICE_UNKNOWN) + + +_DEVICE_TYPE_MAP: dict[DeviceType, StandardIcon] = { + DeviceType.Any: StandardIcon.DEVICE_ANY, + DeviceType.AutoFocus: StandardIcon.DEVICE_AUTOFOCUS, + DeviceType.Camera: StandardIcon.DEVICE_CAMERA, + DeviceType.Core: StandardIcon.DEVICE_CORE, + DeviceType.Galvo: StandardIcon.DEVICE_GALVO, + DeviceType.Generic: StandardIcon.DEVICE_GENERIC, + DeviceType.Hub: StandardIcon.DEVICE_HUB, + DeviceType.ImageProcessor: StandardIcon.DEVICE_IMAGEPROCESSOR, + DeviceType.Magnifier: StandardIcon.DEVICE_MAGNIFIER, + DeviceType.Shutter: StandardIcon.DEVICE_SHUTTER, + DeviceType.SignalIO: StandardIcon.DEVICE_SIGNALIO, + DeviceType.SLM: StandardIcon.DEVICE_SLM, + DeviceType.Stage: StandardIcon.DEVICE_STAGE, + DeviceType.State: StandardIcon.DEVICE_STATE, + DeviceType.Unknown: StandardIcon.DEVICE_UNKNOWN, + DeviceType.XYStage: StandardIcon.DEVICE_XYSTAGE, + DeviceType.Serial: StandardIcon.DEVICE_SERIAL, +} diff --git a/src/pymmcore_widgets/_models/_config_group_pivot_model.py b/src/pymmcore_widgets/_models/_config_group_pivot_model.py index 52930f031..c637db1da 100644 --- a/src/pymmcore_widgets/_models/_config_group_pivot_model.py +++ b/src/pymmcore_widgets/_models/_config_group_pivot_model.py @@ -4,7 +4,7 @@ from qtpy.QtCore import QAbstractTableModel, QModelIndex, QSize, Qt -from pymmcore_widgets._icons import get_device_icon +from pymmcore_widgets._icons import StandardIcon from ._py_config_model import ConfigPreset, DevicePropertySetting from ._q_config_model import QConfigGroupsModel @@ -155,8 +155,8 @@ def headerData( dev, _prop = self._rows[section] except IndexError: # pragma: no cover return None - if icon := get_device_icon(dev): - return icon.pixmap(QSize(16, 16)) + if icon := StandardIcon.for_device_type(dev): + return icon.icon().pixmap(QSize(16, 16)) return None def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: diff --git a/src/pymmcore_widgets/_models/_py_config_model.py b/src/pymmcore_widgets/_models/_py_config_model.py index 0142389b0..d141e8f2e 100644 --- a/src/pymmcore_widgets/_models/_py_config_model.py +++ b/src/pymmcore_widgets/_models/_py_config_model.py @@ -6,7 +6,7 @@ from pymmcore_plus import DeviceType, Keyword, PropertyType from typing_extensions import TypeAlias -from pymmcore_widgets._icons import DEVICE_TYPE_ICON, StandardIcon +from pymmcore_widgets._icons import StandardIcon if TYPE_CHECKING: from collections.abc import Hashable @@ -43,7 +43,7 @@ def is_loaded(self) -> bool: @property def iconify_key(self) -> str | None: """Return an iconify key for the device type.""" - return DEVICE_TYPE_ICON.get(self.type, None) + return StandardIcon.for_device_type(self.type) def key(self) -> Hashable: """Return a unique key for the device.""" diff --git a/src/pymmcore_widgets/_models/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py index 5bdf5f6f9..fca260db9 100644 --- a/src/pymmcore_widgets/_models/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -82,9 +82,9 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A return StandardIcon.CONFIG_GROUP.icon().pixmap(16, 16) if node.is_preset: preset = cast("ConfigPreset", node.payload) - if preset.is_system_startup: + if preset.is_system_startup: # pragma: no cover return StandardIcon.STARTUP.icon().pixmap(16, 16) - if preset.is_system_shutdown: + if preset.is_system_shutdown: # pragma: no cover return StandardIcon.SHUTDOWN.icon().pixmap(16, 16) return StandardIcon.CONFIG_PRESET.icon().pixmap(16, 16) if node.is_setting: diff --git a/src/pymmcore_widgets/device_properties/_device_property_table.py b/src/pymmcore_widgets/device_properties/_device_property_table.py index 6cac3b42d..99b589c0e 100644 --- a/src/pymmcore_widgets/device_properties/_device_property_table.py +++ b/src/pymmcore_widgets/device_properties/_device_property_table.py @@ -7,9 +7,8 @@ from qtpy.QtCore import Qt from qtpy.QtGui import QColor from qtpy.QtWidgets import QAbstractScrollArea, QTableWidget, QTableWidgetItem, QWidget -from superqt.iconify import QIconifyIcon -from pymmcore_widgets._icons import DEVICE_TYPE_ICON +from pymmcore_widgets._icons import StandardIcon from pymmcore_widgets._util import NoWheelTableWidget from ._property_widget import PropertyWidget @@ -129,8 +128,8 @@ def _rebuild_table(self) -> None: extra = " 🅿" if prop.isPreInit() else "" item = QTableWidgetItem(f"{prop.device}-{prop.name}{extra}") item.setData(self.PROP_ROLE, prop) - if icon_string := DEVICE_TYPE_ICON.get(prop.deviceType()): - item.setIcon(QIconifyIcon(icon_string, color="Gray")) + if icon := StandardIcon.for_device_type(prop.deviceType()): + item.setIcon(icon.icon()) self.setItem(i, 0, item) try: diff --git a/src/pymmcore_widgets/hcwizard/devices_page.py b/src/pymmcore_widgets/hcwizard/devices_page.py index 8526edbef..bad40847e 100644 --- a/src/pymmcore_widgets/hcwizard/devices_page.py +++ b/src/pymmcore_widgets/hcwizard/devices_page.py @@ -25,7 +25,7 @@ from superqt.iconify import QIconifyIcon from superqt.utils import exceptions_as_dialog, signals_blocked -from pymmcore_widgets._icons import DEVICE_TYPE_ICON +from pymmcore_widgets._icons import StandardIcon from ._base_page import ConfigWizardPage from ._dev_setup_dialog import DeviceSetupDialog @@ -60,10 +60,10 @@ def rebuild(self, model: Microscope, errs: dict[str, str] | None = None) -> None self.clearContents() self.setRowCount(len(model.devices)) for i, device in enumerate(model.devices): - type_icon = DEVICE_TYPE_ICON.get(device.device_type, "") + type_icon = StandardIcon.for_device_type(device.device_type) wdg: QWidget if device.device_type == DeviceType.Hub: - wdg = QPushButton(QIconifyIcon(type_icon, color="blue"), "") + wdg = QPushButton(type_icon.icon(color="blue"), "") wdg.setToolTip("Add peripheral device") wdg.setCursor(Qt.CursorShape.PointingHandCursor) wdg.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) @@ -74,7 +74,7 @@ def rebuild(self, model: Microscope, errs: dict[str, str] | None = None) -> None else: wdg = QLabel() - wdg.setPixmap(QIconifyIcon(type_icon, color="gray").pixmap(16, 16)) + wdg.setPixmap(type_icon.icon(color="gray").pixmap(16, 16)) container = QWidget() layout = QHBoxLayout(container) @@ -355,9 +355,8 @@ def rebuild_table(self) -> None: self.table.setItem(i, 1, item) # ----------- item = QTableWidgetItem(str(device.device_type)) - icon_string = DEVICE_TYPE_ICON.get(device.device_type, None) - if icon_string: - item.setIcon(QIconifyIcon(icon_string, color="Gray")) + icon = StandardIcon.for_device_type(device.device_type) + item.setIcon(icon.icon()) if device.library_hub: item.setFlags(Qt.ItemFlag.NoItemFlags) self.table.setItem(i, 2, item) @@ -370,9 +369,8 @@ def rebuild_table(self) -> None: _avail = {x.device_type for x in self._model.available_devices} avail = sorted(x for x in _avail if x != DeviceType.Any) for x in (DeviceType.Any, *avail): - self.dev_type.addItem( - QIconifyIcon(DEVICE_TYPE_ICON.get(x, "")), str(x), x - ) + icon = StandardIcon.for_device_type(x).icon() + self.dev_type.addItem(icon, str(x), x) if current in avail: self.dev_type.setCurrentText(str(current)) diff --git a/tests/test_icons.py b/tests/test_icons.py new file mode 100644 index 000000000..6ec03c96a --- /dev/null +++ b/tests/test_icons.py @@ -0,0 +1,8 @@ +from pymmcore_widgets._icons import StandardIcon + + +def test_standard_icon() -> None: + """Test that StandardIcon can be instantiated.""" + for icon in StandardIcon: + assert not icon.icon().isNull() + assert ":" in str(icon) From 682b798c672187b1741555595769d50de5bd883b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 17:25:39 -0400 Subject: [PATCH 10/14] refactor: simplify duplicate_group and duplicate_preset methods; add test for set_channel_group functionality --- .../_models/_q_config_model.py | 29 +++++-------------- tests/test_config_groups_model.py | 20 +++++++++++++ 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/pymmcore_widgets/_models/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py index fca260db9..4ca8b4e36 100644 --- a/src/pymmcore_widgets/_models/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -226,9 +226,7 @@ def add_group(self, base_name: str = "New Group") -> QModelIndex: return self.index(row, 0) return QModelIndex() # pragma: no cover - def duplicate_group( - self, idx: QModelIndex, new_name: str | None = None - ) -> QModelIndex: + def duplicate_group(self, idx: QModelIndex) -> QModelIndex: node = self._node_from_index(idx) if not isinstance((grp := node.payload), ConfigGroup): warnings.warn("Reference index is not a ConfigGroup.", stacklevel=2) @@ -237,13 +235,8 @@ def duplicate_group( new_grp = deepcopy(grp) new_grp.is_channel_group = False # this never gets duplicated - # Always ensure the name is unique, even if new_name is provided - if new_name is not None: - # Check if the provided name is unique, if not make it unique - new_grp.name = self._unique_child_name(self._root, new_name, suffix="") - else: - # Generate unique name based on original name - new_grp.name = self._unique_child_name(self._root, new_grp.name) + # Generate unique name based on original name + new_grp.name = self._unique_child_name(self._root, new_grp.name) row = idx.row() + 1 if self.insertRows(row, 1, QModelIndex(), _payloads=[new_grp]): @@ -267,9 +260,7 @@ def add_preset( return self.index(row, 0, group_idx) return QModelIndex() # pragma: no cover - def duplicate_preset( - self, preset_index: QModelIndex, new_name: str | None = None - ) -> QModelIndex: + def duplicate_preset(self, preset_index: QModelIndex) -> QModelIndex: pre_node = self._node_from_index(preset_index) if not isinstance((pre := pre_node.payload), ConfigPreset): warnings.warn("Reference index is not a ConfigPreset.", stacklevel=2) @@ -279,19 +270,13 @@ def duplicate_preset( group_idx = preset_index.parent() group_node = self._node_from_index(group_idx) - # Always ensure the name is unique, even if new_name is provided - if new_name is not None: - # Check if the provided name is unique, if not make it unique - pre_copy.name = self._unique_child_name(group_node, new_name, suffix="") - else: - # Generate unique name based on original name - pre_copy.name = self._unique_child_name(group_node, pre_copy.name) + # Generate unique name based on original name + pre_copy.name = self._unique_child_name(group_node, pre_copy.name) row = preset_index.row() + 1 if self.insertRows(row, 1, group_idx, _payloads=[pre_copy]): return self.index(row, 0, group_idx) return QModelIndex() # pragma: no cover - return QModelIndex() # pragma: no cover def set_channel_group(self, group_idx: QModelIndex | None) -> None: """Set the given group as the channel group. @@ -389,7 +374,7 @@ def remove( QMessageBox.StandardButton.Yes, ) if msg != QMessageBox.StandardButton.Yes: - return + return # pragma: no cover self.removeRows(idx.row(), 1, idx.parent()) # ------------------------------------------------------------------ diff --git a/tests/test_config_groups_model.py b/tests/test_config_groups_model.py index 5232b70b6..89bce44b2 100644 --- a/tests/test_config_groups_model.py +++ b/tests/test_config_groups_model.py @@ -240,6 +240,26 @@ def test_update_preset_settings(model: QConfigGroupsModel, qtbot: QtBot) -> None model.update_preset_settings(QModelIndex(), new_settings) +def test_set_channel_Group(model: QConfigGroupsModel, qtbot: QtBot) -> None: + channel_group = {g.name for g in model.get_groups() if g.is_channel_group} + assert channel_group == {"Channel"} + + with qtbot.waitSignal(model.dataChanged): + model.set_channel_group(model.index(0, 0)) + new_channel_group = {g.name for g in model.get_groups() if g.is_channel_group} + assert new_channel_group == {"Camera"} + + with qtbot.assertNotEmitted(model.dataChanged): + model.set_channel_group(model.index(0, 0)) # set to the same thing again + new_channel_group = {g.name for g in model.get_groups() if g.is_channel_group} + assert new_channel_group == {"Camera"} + + with qtbot.waitSignal(model.dataChanged): + model.set_channel_group(QModelIndex()) # reset to no channel group + reset_channel_group = {g.name for g in model.get_groups() if g.is_channel_group} + assert reset_channel_group == set() + + def test_standard_item_model( model: QConfigGroupsModel, qtmodeltester: ModelTester ) -> None: From 53eef8ea5c8a9fa2267c92666db184c1b8484fd0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 17:27:34 -0400 Subject: [PATCH 11/14] remove message --- .../_models/_q_config_model.py | 23 +------------------ .../_views/_config_presets_table.py | 5 +--- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/src/pymmcore_widgets/_models/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py index 4ca8b4e36..facb12f03 100644 --- a/src/pymmcore_widgets/_models/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -7,7 +7,6 @@ from qtpy.QtCore import QModelIndex, Qt from qtpy.QtGui import QFont, QIcon -from qtpy.QtWidgets import QMessageBox, QWidget from superqt import QIconifyIcon from pymmcore_widgets._icons import StandardIcon @@ -353,28 +352,8 @@ def removeRows( self.endRemoveRows() return True - # TODO: probably remove the QWidget logic from here - def remove( - self, - idx: QModelIndex, - *, - ask_confirmation: bool = False, - parent: QWidget | None = None, - ) -> None: + def remove(self, idx: QModelIndex) -> None: if idx.isValid(): - if ask_confirmation: - item_name = idx.data(Qt.ItemDataRole.DisplayRole) - item_type = type(idx.data(Qt.ItemDataRole.UserRole)) - type_name = item_type.__name__.replace(("Config"), "Config ") - msg = QMessageBox.question( - parent, - "Confirm Deletion", - f"Are you sure you want to delete {type_name} {item_name!r}?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.Yes, - ) - if msg != QMessageBox.StandardButton.Yes: - return # pragma: no cover self.removeRows(idx.row(), 1, idx.parent()) # ------------------------------------------------------------------ diff --git a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py index a4955f7af..e77a0f806 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os from contextlib import suppress from typing import TYPE_CHECKING @@ -26,8 +25,6 @@ else: from qtpy.QtGui import QAction -NOT_TESTING = "PYTEST_VERSION" not in os.environ - class ConfigPresetsTableView(QTableView): """Plain QTableView for displaying configuration presets. @@ -205,7 +202,7 @@ def _on_remove_action(self) -> None: return source_model = self.view.sourceModel() - source_model.remove(source_idx, ask_confirmation=NOT_TESTING) + source_model.remove(source_idx) def _on_duplicate_action(self) -> None: if not self.view.isTransposed(): From 5e06293cb391e1655d7b54493af0c86aae298ad3 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 19:57:42 -0400 Subject: [PATCH 12/14] refactor: add coverage comments for edge cases in _Node and ConfigGroupPivotModel; add test for get_loaded_devices --- src/pymmcore_widgets/_models/_base_tree_model.py | 4 ++-- .../_models/_config_group_pivot_model.py | 4 ++-- tests/test_config_groups_model.py | 1 + tests/test_py_model.py | 15 +++++++++++++++ 4 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 tests/test_py_model.py diff --git a/src/pymmcore_widgets/_models/_base_tree_model.py b/src/pymmcore_widgets/_models/_base_tree_model.py index 1905daa4c..87c353df4 100644 --- a/src/pymmcore_widgets/_models/_base_tree_model.py +++ b/src/pymmcore_widgets/_models/_base_tree_model.py @@ -70,7 +70,7 @@ def __init__( @property def siblings(self) -> list[_Node]: if self.parent is None: - return [] + return [] # pragma: no cover return [x for x in self.parent.children if x is not self] def num_children(self) -> int: @@ -78,7 +78,7 @@ def num_children(self) -> int: def row_in_parent(self) -> int: if self.parent is None: - return -1 + return -1 # pragma: no cover try: return self.parent.children.index(self) except ValueError: # pragma: no cover diff --git a/src/pymmcore_widgets/_models/_config_group_pivot_model.py b/src/pymmcore_widgets/_models/_config_group_pivot_model.py index c637db1da..a1277f24a 100644 --- a/src/pymmcore_widgets/_models/_config_group_pivot_model.py +++ b/src/pymmcore_widgets/_models/_config_group_pivot_model.py @@ -112,7 +112,7 @@ def _rebuild(self) -> None: # slot signature is flexible try: node = self._gidx.internalPointer() if not node: - return + return # pragma: no cover self._presets = [child.payload for child in node.children] keys = (setting.key() for p in self._presets for setting in p.settings) self._rows = list(dict.fromkeys(keys, None)) # unique (device, prop) pairs @@ -213,7 +213,7 @@ def _should_rebuild_for_changes( ) -> bool: """Determine if model changes require rebuilding the pivot.""" if self._gidx is None or self._src is None: - return False + return False # pragma: no cover tl_col = top_left.column() tl_par = top_left.parent() diff --git a/tests/test_config_groups_model.py b/tests/test_config_groups_model.py index 89bce44b2..5b632e468 100644 --- a/tests/test_config_groups_model.py +++ b/tests/test_config_groups_model.py @@ -270,6 +270,7 @@ def test_pivot_model(model: QConfigGroupsModel, qtmodeltester: ModelTester) -> N pivot = ConfigGroupPivotModel() pivot.setSourceModel(model) pivot.setGroup("Channel") + pivot.setGroup(pivot.index(1, 0)) # set by index qtmodeltester.check(pivot) diff --git a/tests/test_py_model.py b/tests/test_py_model.py new file mode 100644 index 000000000..3f8e58c8f --- /dev/null +++ b/tests/test_py_model.py @@ -0,0 +1,15 @@ +from pymmcore_plus import CMMCorePlus + +from pymmcore_widgets._models import ( + get_available_devices, + get_config_groups, + get_loaded_devices, +) + + +def test_get_loaded_devices() -> None: + core = CMMCorePlus() + core.loadSystemConfiguration() + get_loaded_devices(core) + get_available_devices(core) + get_config_groups(core) From b0108cc21387bec4b24389126cd0507bdb3c5652 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 20:03:13 -0400 Subject: [PATCH 13/14] test: add validation checks for name changes in QConfigGroupsModel --- tests/test_config_groups_model.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/test_config_groups_model.py b/tests/test_config_groups_model.py index 5b632e468..d33fc3884 100644 --- a/tests/test_config_groups_model.py +++ b/tests/test_config_groups_model.py @@ -240,7 +240,20 @@ def test_update_preset_settings(model: QConfigGroupsModel, qtbot: QtBot) -> None model.update_preset_settings(QModelIndex(), new_settings) -def test_set_channel_Group(model: QConfigGroupsModel, qtbot: QtBot) -> None: +def test_name_change_valid(model: QConfigGroupsModel, qtbot: QtBot) -> None: + assert model.is_name_change_valid(model.index(0), "Camera") is None # same name + assert model.is_name_change_valid(model.index(0), " ") == "Name cannot be empty" + assert model.is_name_change_valid(model.index(0), "New Group Name") is None + assert ( + model.is_name_change_valid(model.index(0), "Channel") + == "Name 'Channel' already exists" + ) + assert ( + model.is_name_change_valid(QModelIndex(), "Camera") == "Cannot rename root node" + ) + + +def test_set_channel_group(model: QConfigGroupsModel, qtbot: QtBot) -> None: channel_group = {g.name for g in model.get_groups() if g.is_channel_group} assert channel_group == {"Channel"} From 3bdfc07e178d74b742673d4f99c87e47555623e6 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 20:11:29 -0400 Subject: [PATCH 14/14] test: add unit test for updating preset properties in QConfigGroupsModel --- tests/test_config_groups_model.py | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_config_groups_model.py b/tests/test_config_groups_model.py index d33fc3884..fdb41a803 100644 --- a/tests/test_config_groups_model.py +++ b/tests/test_config_groups_model.py @@ -240,6 +240,59 @@ def test_update_preset_settings(model: QConfigGroupsModel, qtbot: QtBot) -> None model.update_preset_settings(QModelIndex(), new_settings) +def test_update_preset_properties(model: QConfigGroupsModel, qtbot: QtBot) -> None: + """Test updating preset properties.""" + # Get original data + original_data = model.get_groups() + preset0 = next(iter(original_data[0].presets.values())) + original_settings_count = len(preset0.settings) + assert original_settings_count > 1 + + # Get the first two existing settings as (device, property_name) tuples + existing_setting1 = preset0.settings[0] + existing_setting2 = preset0.settings[1] + existing_key1 = existing_setting1.key() + existing_key2 = existing_setting2.key() + + grp0_index = model.index(0, 0) + preset0_index = model.index(0, 0, grp0_index) + + # Test updating with a mix of existing and new properties + new_properties = [ + existing_key1, # Keep existing setting + existing_key2, # Keep another existing setting + ("NewDevice", "NewProperty"), # Add new placeholder setting + ] + + model.update_preset_properties(preset0_index, new_properties) + + # Verify the changes + new_data = model.get_groups() + preset0_new = next(iter(new_data[0].presets.values())) + + # Should have exactly 3 settings now + assert len(preset0_new.settings) == 3 + + # Check that existing settings are preserved with their values + settings_by_key = {s.key(): s for s in preset0_new.settings} + assert existing_key1 in settings_by_key + assert existing_key2 in settings_by_key + assert ("NewDevice", "NewProperty") in settings_by_key + + # Verify existing settings kept their values + assert settings_by_key[existing_key1].value == existing_setting1.value + assert settings_by_key[existing_key2].value == existing_setting2.value + + # Verify new setting has empty value + assert settings_by_key[("NewDevice", "NewProperty")].value == "" + assert settings_by_key[("NewDevice", "NewProperty")].device_label == "NewDevice" + assert settings_by_key[("NewDevice", "NewProperty")].property_name == "NewProperty" + + # Test with invalid index + with pytest.warns(UserWarning, match="Reference index is not a ConfigPreset."): + model.update_preset_properties(QModelIndex(), new_properties) + + def test_name_change_valid(model: QConfigGroupsModel, qtbot: QtBot) -> None: assert model.is_name_change_valid(model.index(0), "Camera") is None # same name assert model.is_name_change_valid(model.index(0), " ") == "Name cannot be empty"