From 0b602375b46f997052509f6fe1615ebf28a11436 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Jun 2025 11:27:15 -0400 Subject: [PATCH 01/70] wip --- example.py | 465 ++++++++++++++++++ .../device_properties/__init__.py | 8 +- 2 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 example.py diff --git a/example.py b/example.py new file mode 100644 index 000000000..f7f218dcc --- /dev/null +++ b/example.py @@ -0,0 +1,465 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Callable, cast + +from pymmcore_plus import CMMCorePlus +from pymmcore_plus.model import ConfigGroup, ConfigPreset, Setting +from PyQt6.QtCore import QAbstractItemModel, QModelIndex, Qt, pyqtSignal +from PyQt6.QtWidgets import ( + QApplication, + QHBoxLayout, + QListView, + QMessageBox, + QSplitter, + QToolBar, + QTreeView, + QVBoxLayout, + QWidget, +) + +from pymmcore_widgets.device_properties import DevicePropertyTable + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + +# ----------------------------------------------------------------------------- +# Internal tree node helper +# ----------------------------------------------------------------------------- + + +class _Node: + """Generic tree node that wraps a ConfigGroup or ConfigPreset.""" + + def __init__( + self, + name: str, + payload: ConfigGroup | ConfigPreset | 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) + + +# ----------------------------------------------------------------------------- +# ConfigTreeModel +# ----------------------------------------------------------------------------- + + +class ConfigTreeModel(QAbstractItemModel): + """Two-level model: root → groups → presets.""" + + # emitted when underlying data change in any way + dataChangedExternally = pyqtSignal() + + def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: + super().__init__() + self._root = _Node("") + if groups: + self._build_tree(groups) + + # ------------------------------------------------------------------ + # Public helpers used by the widget toolbar actions + # ------------------------------------------------------------------ + + # group-level ------------------------------------------------------------- + + def add_group(self, base_name: str = "Group") -> QModelIndex: + """Append a *new* empty group and return its QModelIndex.""" + name = self._unique_child_name(self._root, base_name) + group = ConfigGroup(name) + node = _Node(name, group, self._root) + return self._insert_node(node, self._root, len(self._root.children)) + + def duplicate_group(self, idx: QModelIndex) -> QModelIndex: + if not self._is_group_index(idx): + return QModelIndex() + orig = cast("_Node", idx.internalPointer()) + new_grp = deepcopy(orig.payload) + assert isinstance(new_grp, ConfigGroup) + new_grp.name = self._unique_child_name(self._root, new_grp.name) + node = _Node(new_grp.name, new_grp, self._root) + # duplicate presets + for p in new_grp.presets.values(): + child_node = _Node(p.name, p, node) + node.children.append(child_node) + return self._insert_node(node, self._root, idx.row() + 1) + + # preset-level ------------------------------------------------------------ + + def add_preset( + self, group_idx: QModelIndex, base_name: str = "Preset" + ) -> QModelIndex: + if not self._is_group_index(group_idx): + return QModelIndex() + parent_node = cast("_Node", group_idx.internalPointer()) + name = self._unique_child_name(parent_node, base_name) + preset = ConfigPreset(name) + node = _Node(name, preset, parent_node) + return self._insert_node(node, parent_node, len(parent_node.children)) + + def duplicate_preset(self, idx: QModelIndex) -> QModelIndex: + if not self._is_preset_index(idx): + return QModelIndex() + parent_node = cast("_Node", idx.parent().internalPointer()) + orig = cast("_Node", idx.internalPointer()) + new_preset = deepcopy(orig.payload) + assert isinstance(new_preset, ConfigPreset) + new_preset.name = self._unique_child_name(parent_node, new_preset.name) + node = _Node(new_preset.name, new_preset, parent_node) + return self._insert_node(node, parent_node, idx.row() + 1) + + # generic remove ---------------------------------------------------------- + + def remove(self, idx: QModelIndex) -> None: + if not idx.isValid(): + return + node = cast("_Node", idx.internalPointer()) + parent_node = node.parent + if parent_node is None: + return + self.beginRemoveRows(idx.parent(), idx.row(), idx.row()) + parent_node.children.pop(idx.row()) + self.endRemoveRows() + self.dataChangedExternally.emit() + + # ------------------------------------------------------------------ + # Required Qt model overrides + # ------------------------------------------------------------------ + + # structure helpers ------------------------------------------------------- + + def _node(self, index: QModelIndex | None) -> _Node: + return ( + cast("_Node", index.internalPointer()) + if index and index.isValid() + else self._root + ) + + def rowCount(self, parent: QModelIndex | None = None) -> int: + return len(self._node(parent).children) + + def columnCount(self, _parent: QModelIndex | None = None) -> int: + return 1 + + def index( + self, row: int, column: int, parent: QModelIndex | None = None + ) -> QModelIndex: + if column != 0: + return QModelIndex() + parent_node = self._node(parent) + if 0 <= row < len(parent_node.children): + return self.createIndex(row, 0, parent_node.children[row]) + return QModelIndex() + + def parent(self, child: QModelIndex) -> QModelIndex: # type: ignore[override] + """Returns the parent of the model item with the given index. + + If the item has no parent, an invalid QModelIndex is returned. + """ + if not child.isValid(): + return QModelIndex() + parent_node = cast("_Node", child.internalPointer()).parent + if parent_node is self._root or parent_node is None: + return QModelIndex() + return self.createIndex(parent_node.row_in_parent(), 0, parent_node) + + # data & editing ---------------------------------------------------------- + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid() or role not in ( + Qt.ItemDataRole.DisplayRole, + Qt.ItemDataRole.EditRole, + ): + return None + return cast("_Node", index.internalPointer()).name + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + if not index.isValid(): + return Qt.ItemFlag.NoItemFlags + fl = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled + # allow renaming + fl |= Qt.ItemFlag.ItemIsEditable + return fl + + def setData( + self, + index: QModelIndex, + value: Any, + role: int = Qt.ItemDataRole.EditRole, + ) -> bool: + if role != Qt.ItemDataRole.EditRole or not index.isValid(): + return False + node = cast("_Node", index.internalPointer()) + new_name = cast("str", value) + if new_name == node.name: + return True + if self._name_exists(node.parent, new_name): + self._show_dup_name_error(new_name) + return False + node.name = new_name + if node.payload is not None: + node.payload.name = new_name # keep dataclass in sync + self.dataChanged.emit(index, index, [role]) + self.dataChangedExternally.emit() + return True + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _build_tree(self, groups: Iterable[ConfigGroup]) -> None: + self._root.children.clear() + for g in groups: + gnode = _Node(g.name, g, self._root) + self._root.children.append(gnode) + for p in g.presets.values(): + pnode = _Node(p.name, p, gnode) + gnode.children.append(pnode) + + # name uniqueness --------------------------------------------------------- + + @staticmethod + def _unique_child_name(parent: _Node, base: str) -> str: + names = {c.name for c in parent.children} + if base not in names: + return base + i = 1 + while f"{base} {i}" in names: + i += 1 + return f"{base} {i}" + + @staticmethod + def _name_exists(parent: _Node | None, name: str) -> bool: + return parent is not None and any(c.name == name for c in parent.children) + + @staticmethod + def _show_dup_name_error(name: str) -> None: + QMessageBox.warning(None, "Duplicate name", f"Name '{name}' already exists.") + + # convenience guards ------------------------------------------------------ + + @staticmethod + def _is_group_index(idx: QModelIndex) -> bool: + return idx.isValid() and cast("_Node", idx.internalPointer()).is_group + + @staticmethod + def _is_preset_index(idx: QModelIndex) -> bool: + return idx.isValid() and cast("_Node", idx.internalPointer()).is_preset + + # insertion --------------------------------------------------------------- + + def _insert_node(self, node: _Node, parent_node: _Node, row: int) -> QModelIndex: + self.beginInsertRows(self._index_from_node(parent_node), row, row) + parent_node.children.insert(row, node) + self.endInsertRows() + self.dataChangedExternally.emit() + return self.createIndex(row, 0, node) + + def _index_from_node(self, node: _Node) -> QModelIndex: + if node is self._root: + return QModelIndex() + return self.createIndex(node.row_in_parent(), 0, node) + + # external data API ------------------------------------------------------- + + def set_data(self, groups: Iterable[ConfigGroup]) -> None: + self.beginResetModel() + self._build_tree(groups) + self.endResetModel() + self.dataChangedExternally.emit() + + def data_as_groups(self) -> list[ConfigGroup]: + """Return a *deep copy* of current configuration as dataclasses.""" + return deepcopy([cast("ConfigGroup", n.payload) for n in self._root.children]) + + +# ----------------------------------------------------------------------------- +# Property table placeholder (unchanged) +# ----------------------------------------------------------------------------- + + +# ----------------------------------------------------------------------------- +# High-level editor widget +# ----------------------------------------------------------------------------- + + +class ConfigEditor(QWidget): + """Widget composed of two QListViews backed by a single tree model.""" + + configChanged = pyqtSignal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._model = ConfigTreeModel() + + # views -------------------------------------------------------------- + self._group_view = QListView() + self._group_view.setModel(self._model) + self._group_view.setSelectionMode(QListView.SelectionMode.SingleSelection) + + self._preset_view = QListView() + self._preset_view.setModel(self._model) + self._preset_view.setSelectionMode(QListView.SelectionMode.SingleSelection) + + # toolbars ----------------------------------------------------------- + self._group_tb = self._make_tb(self._new_group, self._remove, self._dup_group) + self._preset_tb = self._make_tb( + self._new_preset, self._remove, self._dup_preset + ) + + # layout ------------------------------------------------------------- + left = QWidget() + lv = QVBoxLayout(left) + lv.setContentsMargins(0, 0, 0, 0) + lv.addWidget(self._group_tb) + lv.addWidget(self._group_view) + lv.addWidget(self._preset_tb) + lv.addWidget(self._preset_view) + + splitter = QSplitter() + # left-hand panel + splitter.addWidget(left) + + # center placeholder property table + self._prop_table = DevicePropertyTable() + self._prop_table.setRowsCheckable(True) + self._prop_table.filterDevices(include_pre_init=False, include_read_only=False) + splitter.addWidget(self._prop_table) + + # right-hand tree view showing the *same* model + self._tree_view = QTreeView() + self._tree_view.setModel(self._model) + self._tree_view.expandAll() # helpful for the demo + splitter.addWidget(self._tree_view) + + splitter.setStretchFactor(1, 1) # property table expands + splitter.setStretchFactor(2, 1) # tree view expands + + lay = QHBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(splitter) + + # signals ------------------------------------------------------------ + self._group_view.selectionModel().currentChanged.connect(self._on_group_sel) + self._model.dataChangedExternally.connect(self.configChanged) + + # ------------------------------------------------------------------ + # Public API required by spec + # ------------------------------------------------------------------ + + def setData(self, data: Iterable[ConfigGroup]) -> None: + self._model.set_data(data) + self._group_view.setCurrentIndex(QModelIndex()) + self._preset_view.setRootIndex(QModelIndex()) + self.configChanged.emit() + + def data(self) -> Sequence[ConfigGroup]: + return self._model.data_as_groups() + + # ------------------------------------------------------------------ + # Toolbar action helpers + # ------------------------------------------------------------------ + + @staticmethod + def _make_tb(new_fn: Callable, rem_fn: Callable, dup_fn: Callable) -> QToolBar: + tb = QToolBar() + tb.addAction("New", new_fn) + tb.addAction("Remove", rem_fn) + tb.addAction("Duplicate", dup_fn) + return tb + + def _current_group_index(self) -> QModelIndex: + return self._group_view.currentIndex() + + def _current_preset_index(self) -> QModelIndex: + return self._preset_view.currentIndex() + + # group actions ---------------------------------------------------------- + + def _new_group(self) -> None: + idx = self._model.add_group() + self._group_view.setCurrentIndex(idx) + + def _dup_group(self) -> None: + idx = self._current_group_index() + if idx.isValid(): + self._group_view.setCurrentIndex(self._model.duplicate_group(idx)) + + # preset actions --------------------------------------------------------- + + def _new_preset(self) -> None: + gidx = self._current_group_index() + if not gidx.isValid(): + return + pidx = self._model.add_preset(gidx) + self._preset_view.setCurrentIndex(pidx) + + def _dup_preset(self) -> None: + pidx = self._current_preset_index() + if pidx.isValid(): + self._preset_view.setCurrentIndex(self._model.duplicate_preset(pidx)) + + # shared -------------------------------------------------------------- + + def _remove(self) -> None: + # Determine which view called us based on focus + view = self._preset_view if self._preset_view.hasFocus() else self._group_view + idx = view.currentIndex() + self._model.remove(idx) + + # selection sync --------------------------------------------------------- + + def _on_group_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: + self._preset_view.setRootIndex(current) + if current.isValid() and self._model.rowCount(current): + self._preset_view.setCurrentIndex(self._model.index(0, 0, current)) + else: + self._preset_view.clearSelection() + + +# ----------------------------------------------------------------------------- +# Demo +# ----------------------------------------------------------------------------- + + +if __name__ == "__main__": + import sys + + app = QApplication(sys.argv) + core = CMMCorePlus() + core.loadSystemConfiguration() + + # sample config ---------------------------------------------------------- + cam_grp = ConfigGroup( + "Camera", + presets={ + "Cy5": ConfigPreset("Cy5", [Setting("Camera", "Exposure", "10")]), + "FITC": ConfigPreset("FITC", [Setting("Camera", "Exposure", "5")]), + }, + ) + obj_grp = ConfigGroup("Objective") + + w = ConfigEditor() + w.setData([cam_grp, obj_grp]) + w.resize(800, 600) + w.show() + sys.exit(app.exec()) diff --git a/src/pymmcore_widgets/device_properties/__init__.py b/src/pymmcore_widgets/device_properties/__init__.py index ce53b040e..44bc68c64 100644 --- a/src/pymmcore_widgets/device_properties/__init__.py +++ b/src/pymmcore_widgets/device_properties/__init__.py @@ -1,7 +1,13 @@ """Widgets related to device properties.""" +from ._device_property_table import DevicePropertyTable from ._properties_widget import PropertiesWidget from ._property_browser import PropertyBrowser from ._property_widget import PropertyWidget -__all__ = ["PropertiesWidget", "PropertyBrowser", "PropertyWidget"] +__all__ = [ + "DevicePropertyTable", + "PropertiesWidget", + "PropertyBrowser", + "PropertyWidget", +] From db619c8bbb7d357497dd46d642da44bde42ac237 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Jun 2025 11:44:33 -0400 Subject: [PATCH 02/70] use dev prop table --- example.py | 54 ++++++- .../_device_property_table.py | 150 +++++++++++++++--- 2 files changed, 183 insertions(+), 21 deletions(-) diff --git a/example.py b/example.py index f7f218dcc..23bd71101 100644 --- a/example.py +++ b/example.py @@ -343,6 +343,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._prop_table = DevicePropertyTable() self._prop_table.setRowsCheckable(True) self._prop_table.filterDevices(include_pre_init=False, include_read_only=False) + self._prop_table.valueChanged.connect(self._on_prop_table_changed) splitter.addWidget(self._prop_table) # right-hand tree view showing the *same* model @@ -360,6 +361,7 @@ def __init__(self, parent: QWidget | None = None) -> None: # signals ------------------------------------------------------------ self._group_view.selectionModel().currentChanged.connect(self._on_group_sel) + self._preset_view.selectionModel().currentChanged.connect(self._on_preset_sel) self._model.dataChangedExternally.connect(self.configChanged) # ------------------------------------------------------------------ @@ -370,6 +372,7 @@ def setData(self, data: Iterable[ConfigGroup]) -> None: self._model.set_data(data) self._group_view.setCurrentIndex(QModelIndex()) self._preset_view.setRootIndex(QModelIndex()) + self._prop_table.setValue([]) self.configChanged.emit() def data(self) -> Sequence[ConfigGroup]: @@ -435,6 +438,37 @@ def _on_group_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: else: self._preset_view.clearSelection() + # ------------------------------------------------------------------ + # Property‑table sync + # ------------------------------------------------------------------ + + def _on_preset_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: + """Populate the DevicePropertyTable whenever the selected preset changes.""" + if not current.isValid(): + # clear table when nothing is selected + self._prop_table.setValue([]) + return + node = cast("_Node", current.internalPointer()) + if not node.is_preset: + self._prop_table.setValue([]) + return + preset = cast("ConfigPreset", node.payload) + self._prop_table.setValue(preset.settings) + + def _on_prop_table_changed(self) -> None: + """Write back edits from the table into the underlying ConfigPreset.""" + idx = self._current_preset_index() + if not idx.isValid(): + return + node = cast("_Node", idx.internalPointer()) + if not node.is_preset: + return + preset = cast("ConfigPreset", node.payload) + preset.settings = self._prop_table.value() + # notify observers that model content changed + self._model.dataChangedExternally.emit() + self.configChanged.emit() + # ----------------------------------------------------------------------------- # Demo @@ -452,8 +486,24 @@ def _on_group_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: cam_grp = ConfigGroup( "Camera", presets={ - "Cy5": ConfigPreset("Cy5", [Setting("Camera", "Exposure", "10")]), - "FITC": ConfigPreset("FITC", [Setting("Camera", "Exposure", "5")]), + "Cy5": ConfigPreset( + name="Cy5", + settings=[ + Setting("Dichroic", "Label", "400DCLP"), + Setting("Emission", "Label", "Chroma-HQ700"), + Setting("Excitation", "Label", "Chroma-HQ570"), + Setting("Core", "Shutter", "White Light Shutter"), + ], + ), + "FITC": ConfigPreset( + name="FITC", + settings=[ + Setting("Dichroic", "Label", "400DCLP"), + Setting("Emission", "Label", "Chroma-HQ620"), + Setting("Excitation", "Label", "Chroma-D360"), + Setting("Core", "Shutter", "White Light Shutter"), + ], + ), }, ) obj_grp = ConfigGroup("Objective") diff --git a/src/pymmcore_widgets/device_properties/_device_property_table.py b/src/pymmcore_widgets/device_properties/_device_property_table.py index 57ee3673c..e379a8269 100644 --- a/src/pymmcore_widgets/device_properties/_device_property_table.py +++ b/src/pymmcore_widgets/device_properties/_device_property_table.py @@ -1,13 +1,17 @@ from __future__ import annotations +from collections.abc import Iterable from logging import getLogger -from typing import TYPE_CHECKING, cast +from re import Pattern +from typing import TYPE_CHECKING, Callable, cast from pymmcore_plus import CMMCorePlus, DeviceProperty, DeviceType -from qtpy.QtCore import Qt +from pymmcore_plus.model import Setting +from qtpy.QtCore import Qt, Signal from qtpy.QtGui import QColor from qtpy.QtWidgets import QAbstractScrollArea, QTableWidget, QTableWidgetItem, QWidget from superqt.iconify import QIconifyIcon +from superqt.utils import signals_blocked from pymmcore_widgets._icons import ICONS from pymmcore_widgets._util import NoWheelTableWidget @@ -39,6 +43,7 @@ class DevicePropertyTable(NoWheelTableWidget): will not update the core. By default, True. """ + valueChanged = Signal() PROP_ROLE = QTableWidgetItem.ItemType.UserType + 1 def __init__( @@ -52,6 +57,7 @@ def __init__( rows = 0 cols = 2 super().__init__(rows, cols, parent) + self._rows_checkable: bool = False self._prop_widgets_enabled: bool = enable_property_widgets self._connect_core = connect_core @@ -59,6 +65,7 @@ def __init__( self._mmc = mmcore or CMMCorePlus.instance() self._mmc.events.systemConfigurationLoaded.connect(self._rebuild_table) + self.itemChanged.connect(self._on_item_changed) # If we enable these, then the edit group dialog will lose all of it's checks # whenever modify group button is clicked. However, We don't want this widget # to have to be aware of a current group (or do we?) @@ -69,7 +76,7 @@ def __init__( self.destroyed.connect(self._disconnect) self.setMinimumWidth(500) - self.setHorizontalHeaderLabels(["Property", "Value"]) + self.setHorizontalHeaderLabels(["Device-Property", "Value"]) self.setSizeAdjustPolicy(QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents) self.horizontalHeader().setStretchLastSection(True) @@ -85,6 +92,22 @@ def __init__( self.resize(500, 500) self._rebuild_table() + def _on_item_changed(self, item: QTableWidgetItem) -> None: + if self._rows_checkable: + # set item style based on check state + color = self.palette().color(self.foregroundRole()) + font = item.font() + if item.checkState() == Qt.CheckState.Checked: + color.setAlpha(255) + font.setBold(True) + else: + color.setAlpha(130) + font.setBold(False) + with signals_blocked(self): + item.setForeground(color) + item.setFont(font) + self.valueChanged.emit() + def _disconnect(self) -> None: self._mmc.events.systemConfigurationLoaded.disconnect(self._rebuild_table) # self._mmc.events.configGroupDeleted.disconnect(self._rebuild_table) @@ -121,6 +144,13 @@ def setRowsCheckable(self, checkable: bool = True) -> None: self.item(row, 0).setFlags(flags) def _rebuild_table(self) -> None: + self.blockSignals(True) + try: + self._rebuild_table_inner() + finally: + self.blockSignals(False) + + def _rebuild_table_inner(self) -> None: self.clearContents() props = list(self._mmc.iterProperties(as_object=True)) self.setRowCount(len(props)) @@ -140,6 +170,9 @@ def _rebuild_table(self) -> None: mmcore=self._mmc, connect_core=self._connect_core, ) + # TODO: this is an over-emission. if this is a checkable table, + # and the property is not checked, we should not emit. + wdg.valueChanged.connect(self.valueChanged) except Exception as e: logger.error( f"Error creating widget for {prop.device}-{prop.name}: {e}" @@ -168,42 +201,121 @@ def setReadOnlyDevicesVisible(self, visible: bool = True) -> None: def filterDevices( self, - query: str = "", + query: str | Pattern = "", + *, exclude_devices: Iterable[DeviceType] = (), + include_devices: Iterable[DeviceType] = (), include_read_only: bool = True, include_pre_init: bool = True, - init_props_only: bool = False, + always_include_checked: bool = False, + predicate: Callable[[DeviceProperty], bool | None] | None = None, ) -> None: - """Update the table to only show devices that match the given query/filter.""" + """Update the table to only show devices that match the given query/filter. + + Filters are applied in the following order: + 1. If `include_devices` is provided, only devices of the specified types + will be considered. + 2. If `exclude_devices` is provided, devices of the specified types will be + hidden (even if they are in `include_devices`). + 3. If `always_include_checked` is True, remaining rows that are checked will + always be shown, regardless of other filters. + 4. If `predicate` is provided and it returns False, the row is hidden. + 5. If `include_read_only` is False, read-only properties are hidden. + 6. If `include_pre_init` is False, pre-initialized properties are hidden. + 7. Query filtering is applied last, hiding rows that do not match the query. + + Parameters + ---------- + query : str | Pattern, optional + A string or regex pattern to match against the device-property names. + If empty, no filtering is applied, by default "" + exclude_devices : Iterable[DeviceType], optional + A list of device types to exclude from the table, by default () + include_devices : Iterable[DeviceType], optional + A list of device types to include in the table, by default () + include_read_only : bool, optional + Whether to include read-only properties in the table, by default True + include_pre_init : bool, optional + Whether to include pre-initialized properties in the table, by default True + always_include_checked : bool, optional + Whether to always include rows that are checked, by default False. + predicate : Callable[[DeviceProperty, QTableWidgetItem], bool | None] | None + A function that takes a `DeviceProperty` and `QTableWidgetItem` and returns + True to include the row, False to exclude it, or None to skip filtering. + If None, no additional filtering is applied, by default None + """ exclude_devices = set(exclude_devices) + include_devices = set(include_devices) for row in range(self.rowCount()): - item = self.item(row, 0) + if (item := self.item(row, 0)) is None: + continue + prop = cast("DeviceProperty", item.data(self.PROP_ROLE)) - if ( - (prop.isReadOnly() and not include_read_only) - or (prop.isPreInit() and not include_pre_init) - or (init_props_only and not prop.isPreInit()) - or (prop.deviceType() in exclude_devices) - or (query and query.lower() not in item.text().lower()) + dev_type = prop.deviceType() + if (include_devices and dev_type not in include_devices) or ( + exclude_devices and dev_type in exclude_devices ): self.hideRow(row) - else: + continue + + if always_include_checked and item.checkState() == Qt.CheckState.Checked: self.showRow(row) + continue - def getCheckedProperties(self) -> list[tuple[str, str, str]]: + if ( + (predicate and predicate(prop) is False) + or (not include_read_only and prop.isReadOnly()) + or (not include_pre_init and prop.isPreInit()) + ): + self.hideRow(row) + continue + + if query: + if isinstance(query, str) and query.lower() not in item.text().lower(): + self.hideRow(row) + continue + elif isinstance(query, Pattern) and not query.search(item.text()): + self.hideRow(row) + continue + + self.showRow(row) + + def getCheckedProperties(self) -> list[Setting]: """Return a list of checked properties. Each item in the list is a tuple of (device, property, value). """ # list of properties to add to the group # [(device, property, value_to_set), ...] - dev_prop_val_list: list[tuple[str, str, str]] = [] + dev_prop_val_list: list[Setting] = [] + for row in range(self.rowCount()): + if ( + item := self.item(row, 0) + ) and item.checkState() == Qt.CheckState.Checked: + dev_prop_val_list.append(Setting(*self.getRowData(row))) + return dev_prop_val_list + + def value(self) -> list[Setting]: + return self.getCheckedProperties() + + def setValue(self, value: Iterable[tuple[str, str, str]]) -> None: + self.setCheckedProperties(value, with_value=True) + + def setCheckedProperties( + self, + value: Iterable[tuple[str, str, str]], + with_value: bool = True, + ) -> None: for row in range(self.rowCount()): if self.item(row, 0) is None: continue - if self.item(row, 0).checkState() == Qt.CheckState.Checked: - dev_prop_val_list.append(self.getRowData(row)) - return dev_prop_val_list + self.item(row, 0).setCheckState(Qt.CheckState.Unchecked) + for device, prop, *val in value: + if self.item(row, 0).text() == f"{device}-{prop}": + self.item(row, 0).setCheckState(Qt.CheckState.Checked) + wdg = cast("PropertyWidget", self.cellWidget(row, 1)) + if val and with_value: + wdg.setValue(val[0]) def getRowData(self, row: int) -> tuple[str, str, str]: item = self.item(row, 0) From a94126ccee40008a3241fcfaef21ac0891b0a9f1 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Jun 2025 14:05:07 -0400 Subject: [PATCH 03/70] feat: enhance ConfigTreeModel to support settings and update header data --- example.py | 98 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 85 insertions(+), 13 deletions(-) diff --git a/example.py b/example.py index 23bd71101..06d1ad395 100644 --- a/example.py +++ b/example.py @@ -6,6 +6,7 @@ from pymmcore_plus import CMMCorePlus from pymmcore_plus.model import ConfigGroup, ConfigPreset, Setting from PyQt6.QtCore import QAbstractItemModel, QModelIndex, Qt, pyqtSignal +from PyQt6.QtGui import QFont, QIcon from PyQt6.QtWidgets import ( QApplication, QHBoxLayout, @@ -58,6 +59,10 @@ def is_group(self) -> bool: def is_preset(self) -> bool: return isinstance(self.payload, ConfigPreset) + @property + def is_setting(self) -> bool: + return isinstance(self.payload, Setting) + # ----------------------------------------------------------------------------- # ConfigTreeModel @@ -65,7 +70,7 @@ def is_preset(self) -> bool: class ConfigTreeModel(QAbstractItemModel): - """Two-level model: root → groups → presets.""" + """Three-level model: root → groups → presets → settings.""" # emitted when underlying data change in any way dataChangedExternally = pyqtSignal() @@ -158,16 +163,14 @@ def rowCount(self, parent: QModelIndex | None = None) -> int: return len(self._node(parent).children) def columnCount(self, _parent: QModelIndex | None = None) -> int: - return 1 + return 3 def index( self, row: int, column: int, parent: QModelIndex | None = None ) -> QModelIndex: - if column != 0: - return QModelIndex() parent_node = self._node(parent) if 0 <= row < len(parent_node.children): - return self.createIndex(row, 0, parent_node.children[row]) + return self.createIndex(row, column, parent_node.children[row]) return QModelIndex() def parent(self, child: QModelIndex) -> QModelIndex: # type: ignore[override] @@ -185,19 +188,52 @@ def parent(self, child: QModelIndex) -> QModelIndex: # type: ignore[override] # data & editing ---------------------------------------------------------- def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: - if not index.isValid() or role not in ( - Qt.ItemDataRole.DisplayRole, - Qt.ItemDataRole.EditRole, - ): + if not index.isValid(): return None - return cast("_Node", index.internalPointer()).name + + node = cast("_Node", index.internalPointer()) + if role == Qt.ItemDataRole.FontRole and index.column() == 0: + f = QFont() + if node.is_group: + f.setBold(True) + elif node.is_preset: + f.setItalic(True) + return f + + if role == Qt.ItemDataRole.DecorationRole and index.column() == 0: + if node.is_group: + return QIcon.fromTheme("folder") + if node.is_preset: + return QIcon.fromTheme("document") + if node.is_setting: + return QIcon.fromTheme("emblem-system") + + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): + # settings: show Device, Property, Value + if node.is_setting: + setting: Setting = cast("Setting", node.payload) + if index.column() == 0: + return setting.device_name + if index.column() == 1: + return setting.property_name + if index.column() == 2: + return setting.property_value + return None + + # groups / presets: only show name + elif index.column() == 0: + return node.name + return None def flags(self, index: QModelIndex) -> Qt.ItemFlag: if not index.isValid(): return Qt.ItemFlag.NoItemFlags fl = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled - # allow renaming - fl |= Qt.ItemFlag.ItemIsEditable + node = cast("_Node", index.internalPointer()) + if node.is_setting and index.column() == 2: + fl |= Qt.ItemFlag.ItemIsEditable + elif not node.is_setting: + fl |= Qt.ItemFlag.ItemIsEditable return fl def setData( @@ -209,6 +245,25 @@ def setData( if role != Qt.ItemDataRole.EditRole or not index.isValid(): return False node = cast("_Node", index.internalPointer()) + if node.is_setting and index.column() == 2: + setting = cast("Setting", node.payload) + setting = Setting( + setting.device_name, setting.property_name, cast("str", value) + ) + node.payload = setting + # also update the preset.settings list reference + # find node.parent.payload (ConfigPreset) and update list element + parent_preset = cast("ConfigPreset", node.parent.payload) + for i, s in enumerate(parent_preset.settings): + if ( + s.device_name == setting.device_name + and s.property_name == setting.property_name + ): + parent_preset.settings[i] = setting + break + self.dataChanged.emit(index, index, [role]) + self.dataChangedExternally.emit() + return True new_name = cast("str", value) if new_name == node.name: return True @@ -234,6 +289,23 @@ def _build_tree(self, groups: Iterable[ConfigGroup]) -> None: for p in g.presets.values(): pnode = _Node(p.name, p, gnode) gnode.children.append(pnode) + # add one child per Setting + for s in p.settings: + snode = _Node(s.device_name, s, pnode) + pnode.children.append(snode) + + def headerData( + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> Any: + if ( + orientation == Qt.Orientation.Horizontal + and role == Qt.ItemDataRole.DisplayRole + ): + return ["Item", "Property", "Value"][section] if 0 <= section < 3 else None + return super().headerData(section, orientation, role) # name uniqueness --------------------------------------------------------- @@ -510,6 +582,6 @@ def _on_prop_table_changed(self) -> None: w = ConfigEditor() w.setData([cam_grp, obj_grp]) - w.resize(800, 600) + w.resize(1200, 600) w.show() sys.exit(app.exec()) From b8b550e647f1b87b9b534eee2f184a0282a74cfd Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Jun 2025 14:12:45 -0400 Subject: [PATCH 04/70] fix init --- example.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/example.py b/example.py index 06d1ad395..b7cbb492b 100644 --- a/example.py +++ b/example.py @@ -69,7 +69,7 @@ def is_setting(self) -> bool: # ----------------------------------------------------------------------------- -class ConfigTreeModel(QAbstractItemModel): +class _ConfigTreeModel(QAbstractItemModel): """Three-level model: root → groups → presets → settings.""" # emitted when underlying data change in any way @@ -166,7 +166,7 @@ def columnCount(self, _parent: QModelIndex | None = None) -> int: return 3 def index( - self, row: int, column: int, parent: QModelIndex | None = None + self, row: int, column: int = 0, parent: QModelIndex | None = None ) -> QModelIndex: parent_node = self._node(parent) if 0 <= row < len(parent_node.children): @@ -381,7 +381,7 @@ class ConfigEditor(QWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) - self._model = ConfigTreeModel() + self._model = _ConfigTreeModel() # views -------------------------------------------------------------- self._group_view = QListView() @@ -441,13 +441,19 @@ def __init__(self, parent: QWidget | None = None) -> None: # ------------------------------------------------------------------ def setData(self, data: Iterable[ConfigGroup]) -> None: + """Set the configuration data to be displayed in the editor.""" self._model.set_data(data) - self._group_view.setCurrentIndex(QModelIndex()) - self._preset_view.setRootIndex(QModelIndex()) self._prop_table.setValue([]) + # Auto-select first group + if self._model.rowCount(): + self._group_view.setCurrentIndex(self._model.index(0)) + else: + self._preset_view.setRootIndex(QModelIndex()) + self._preset_view.clearSelection() self.configChanged.emit() def data(self) -> Sequence[ConfigGroup]: + """Return the current configuration data as a list of ConfigGroup.""" return self._model.data_as_groups() # ------------------------------------------------------------------ @@ -511,7 +517,7 @@ def _on_group_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: self._preset_view.clearSelection() # ------------------------------------------------------------------ - # Property‑table sync + # Property-table sync # ------------------------------------------------------------------ def _on_preset_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: From c5a1e7d4db594415870cf1b7324b062ca272acae Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Jun 2025 14:37:47 -0400 Subject: [PATCH 05/70] fix bug --- example.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/example.py b/example.py index b7cbb492b..5d712a960 100644 --- a/example.py +++ b/example.py @@ -36,7 +36,7 @@ class _Node: def __init__( self, name: str, - payload: ConfigGroup | ConfigPreset | None = None, + payload: ConfigGroup | ConfigPreset | Setting | None = None, parent: _Node | None = None, ) -> None: self.name = name @@ -178,7 +178,7 @@ def parent(self, child: QModelIndex) -> QModelIndex: # type: ignore[override] If the item has no parent, an invalid QModelIndex is returned. """ - if not child.isValid(): + if not child or not child.isValid(): return QModelIndex() parent_node = cast("_Node", child.internalPointer()).parent if parent_node is self._root or parent_node is None: @@ -294,6 +294,47 @@ def _build_tree(self, groups: Iterable[ConfigGroup]) -> None: snode = _Node(s.device_name, s, pnode) pnode.children.append(snode) + # ------------------------------------------------------------------ + # Public mutator helpers + # ------------------------------------------------------------------ + + def update_preset_settings( + self, preset_idx: QModelIndex, settings: list[Setting] + ) -> None: + """Replace settings and update the tree safely. + + We remove old Setting rows with beginRemoveRows/endRemoveRows, + then insert the new ones. This guarantees attached views drop any + QModelIndex that referenced the old child nodes (avoiding the crash + seen when switching presets). + """ + if not self._is_preset_index(preset_idx): + return + + preset_node = cast("_Node", preset_idx.internalPointer()) + preset: ConfigPreset = cast("ConfigPreset", preset_node.payload) + + # --- mutate underlying dataclass ---------------------------------- + preset.settings = list(settings) + + # --- remove existing Setting rows --------------------------------- + old_row_count = len(preset_node.children) + if old_row_count: + self.beginRemoveRows(preset_idx, 0, old_row_count - 1) + preset_node.children.clear() + self.endRemoveRows() + + # --- insert new Setting rows -------------------------------------- + new_row_count = len(settings) + if new_row_count: + self.beginInsertRows(preset_idx, 0, new_row_count - 1) + for s in settings: + preset_node.children.append(_Node(s.device_name, s, preset_node)) + self.endInsertRows() + + # notify any non-view observers + self.dataChangedExternally.emit() + def headerData( self, section: int, @@ -353,7 +394,7 @@ def _index_from_node(self, node: _Node) -> QModelIndex: # external data API ------------------------------------------------------- - def set_data(self, groups: Iterable[ConfigGroup]) -> None: + def set_groups(self, groups: Iterable[ConfigGroup]) -> None: self.beginResetModel() self._build_tree(groups) self.endResetModel() @@ -442,7 +483,7 @@ def __init__(self, parent: QWidget | None = None) -> None: def setData(self, data: Iterable[ConfigGroup]) -> None: """Set the configuration data to be displayed in the editor.""" - self._model.set_data(data) + self._model.set_groups(data) self._prop_table.setValue([]) # Auto-select first group if self._model.rowCount(): @@ -541,10 +582,8 @@ def _on_prop_table_changed(self) -> None: node = cast("_Node", idx.internalPointer()) if not node.is_preset: return - preset = cast("ConfigPreset", node.payload) - preset.settings = self._prop_table.value() - # notify observers that model content changed - self._model.dataChangedExternally.emit() + new_settings = self._prop_table.value() + self._model.update_preset_settings(idx, new_settings) self.configChanged.emit() From e735fada5390b0385fe82ac50a58434c4a67a9e5 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Jun 2025 14:44:39 -0400 Subject: [PATCH 06/70] two way updates --- example.py | 54 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/example.py b/example.py index 5d712a960..aac37657d 100644 --- a/example.py +++ b/example.py @@ -5,7 +5,12 @@ from pymmcore_plus import CMMCorePlus from pymmcore_plus.model import ConfigGroup, ConfigPreset, Setting -from PyQt6.QtCore import QAbstractItemModel, QModelIndex, Qt, pyqtSignal +from PyQt6.QtCore import ( + QAbstractItemModel, + QModelIndex, + Qt, + pyqtSignal, +) from PyQt6.QtGui import QFont, QIcon from PyQt6.QtWidgets import ( QApplication, @@ -72,9 +77,6 @@ def is_setting(self) -> bool: class _ConfigTreeModel(QAbstractItemModel): """Three-level model: root → groups → presets → settings.""" - # emitted when underlying data change in any way - dataChangedExternally = pyqtSignal() - def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: super().__init__() self._root = _Node("") @@ -144,7 +146,6 @@ def remove(self, idx: QModelIndex) -> None: self.beginRemoveRows(idx.parent(), idx.row(), idx.row()) parent_node.children.pop(idx.row()) self.endRemoveRows() - self.dataChangedExternally.emit() # ------------------------------------------------------------------ # Required Qt model overrides @@ -262,7 +263,6 @@ def setData( parent_preset.settings[i] = setting break self.dataChanged.emit(index, index, [role]) - self.dataChangedExternally.emit() return True new_name = cast("str", value) if new_name == node.name: @@ -274,7 +274,6 @@ def setData( if node.payload is not None: node.payload.name = new_name # keep dataclass in sync self.dataChanged.emit(index, index, [role]) - self.dataChangedExternally.emit() return True # ------------------------------------------------------------------ @@ -332,9 +331,6 @@ def update_preset_settings( preset_node.children.append(_Node(s.device_name, s, preset_node)) self.endInsertRows() - # notify any non-view observers - self.dataChangedExternally.emit() - def headerData( self, section: int, @@ -384,7 +380,6 @@ def _insert_node(self, node: _Node, parent_node: _Node, row: int) -> QModelIndex self.beginInsertRows(self._index_from_node(parent_node), row, row) parent_node.children.insert(row, node) self.endInsertRows() - self.dataChangedExternally.emit() return self.createIndex(row, 0, node) def _index_from_node(self, node: _Node) -> QModelIndex: @@ -398,7 +393,6 @@ def set_groups(self, groups: Iterable[ConfigGroup]) -> None: self.beginResetModel() self._build_tree(groups) self.endResetModel() - self.dataChangedExternally.emit() def data_as_groups(self) -> list[ConfigGroup]: """Return a *deep copy* of current configuration as dataclasses.""" @@ -473,9 +467,11 @@ def __init__(self, parent: QWidget | None = None) -> None: lay.addWidget(splitter) # signals ------------------------------------------------------------ - self._group_view.selectionModel().currentChanged.connect(self._on_group_sel) - self._preset_view.selectionModel().currentChanged.connect(self._on_preset_sel) - self._model.dataChangedExternally.connect(self.configChanged) + if sm := self._group_view.selectionModel(): + sm.currentChanged.connect(self._on_group_sel) + if sm := self._preset_view.selectionModel(): + sm.currentChanged.connect(self._on_preset_sel) + self._model.dataChanged.connect(self._on_model_data_changed) # ------------------------------------------------------------------ # Public API required by spec @@ -586,6 +582,29 @@ def _on_prop_table_changed(self) -> None: self._model.update_preset_settings(idx, new_settings) self.configChanged.emit() + def _on_model_data_changed( + self, + topLeft: QModelIndex, + bottomRight: QModelIndex, + _roles: list[int] | None = None, + ) -> None: + """Refresh DevicePropertyTable if a setting in the current preset was edited.""" + cur_preset = self._current_preset_index() + if not cur_preset.isValid(): + return + + # We only care about edits to rows that are direct children of the + # currently-selected preset (i.e. Setting rows). + if topLeft.parent() != cur_preset: + return + + # pull updated settings from the model and push to the table + node = cast("_Node", cur_preset.internalPointer()) + preset = cast("ConfigPreset", node.payload) + self._prop_table.blockSignals(True) # avoid feedback loop + self._prop_table.setValue(preset.settings) + self._prop_table.blockSignals(False) + # ----------------------------------------------------------------------------- # Demo @@ -607,8 +626,7 @@ def _on_prop_table_changed(self) -> None: name="Cy5", settings=[ Setting("Dichroic", "Label", "400DCLP"), - Setting("Emission", "Label", "Chroma-HQ700"), - Setting("Excitation", "Label", "Chroma-HQ570"), + Setting("Camera", "Gain", "0"), Setting("Core", "Shutter", "White Light Shutter"), ], ), @@ -617,8 +635,6 @@ def _on_prop_table_changed(self) -> None: settings=[ Setting("Dichroic", "Label", "400DCLP"), Setting("Emission", "Label", "Chroma-HQ620"), - Setting("Excitation", "Label", "Chroma-D360"), - Setting("Core", "Shutter", "White Light Shutter"), ], ), }, From 32ca961f7aad7466405caef2e33e9ffcbcf1b186 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Jun 2025 16:48:13 -0400 Subject: [PATCH 07/70] feat: add custom delegate for Setting value editing in ConfigTreeModel --- example.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/example.py b/example.py index aac37657d..2bc7bb2d9 100644 --- a/example.py +++ b/example.py @@ -1,5 +1,6 @@ from __future__ import annotations +from contextlib import suppress from copy import deepcopy from typing import TYPE_CHECKING, Any, Callable, cast @@ -18,13 +19,18 @@ QListView, QMessageBox, QSplitter, + QStyledItemDelegate, + QStyleOptionViewItem, QToolBar, QTreeView, QVBoxLayout, QWidget, ) +from superqt import QIconifyIcon +from pymmcore_widgets._icons import ICONS from pymmcore_widgets.device_properties import DevicePropertyTable +from pymmcore_widgets.device_properties._property_widget import PropertyWidget if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -207,6 +213,12 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A if node.is_preset: return QIcon.fromTheme("document") if node.is_setting: + setting = cast("Setting", node.payload) + with suppress(Exception): + dtype = CMMCorePlus.instance().getDeviceType(setting.device_name) + if icon_string := ICONS.get(dtype): + return QIconifyIcon(icon_string, color="gray").pixmap(16, 16) + return QIcon.fromTheme("emblem-system") if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): @@ -404,6 +416,44 @@ def data_as_groups(self) -> list[ConfigGroup]: # ----------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Delegate: always use QLineEdit for a Setting's value cell (column 2) +# --------------------------------------------------------------------------- +class _SettingValueDelegate(QStyledItemDelegate): + """Provide a plain QLineEdit for editing Setting value cells.""" + + def createEditor( + self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex + ) -> QWidget | None: + node = cast("_Node", index.internalPointer()) + if not (model := index.model()) or (index.column() != 2) or not node.is_setting: + return super().createEditor(parent, option, index) + + row = index.row() + + device = model.data(index.sibling(row, 0), Qt.ItemDataRole.DisplayRole) + prop = model.data(index.sibling(row, 1), Qt.ItemDataRole.DisplayRole) + widget = PropertyWidget(device, prop, parent=parent, connect_core=False) + widget.valueChanged.connect(lambda: self.commitData.emit(widget)) + widget.setAutoFillBackground(True) + return widget + + def setEditorData(self, editor: QWidget, index: QModelIndex) -> None: + if (model := index.model()) and isinstance(editor, PropertyWidget): + data = model.data(index, Qt.ItemDataRole.EditRole) + editor.setValue(data) + else: + super().setEditorData(editor, index) + + def setModelData( + self, editor: QWidget, model: QAbstractItemModel, index: QModelIndex + ) -> None: + if isinstance(editor, PropertyWidget): + model.setData(index, editor.value(), Qt.ItemDataRole.EditRole) + else: + super().setModelData(editor, model, index) + + # ----------------------------------------------------------------------------- # High-level editor widget # ----------------------------------------------------------------------------- @@ -459,6 +509,11 @@ def __init__(self, parent: QWidget | None = None) -> None: self._tree_view.expandAll() # helpful for the demo splitter.addWidget(self._tree_view) + # column 2 (Value) uses a line-edit when editing a Setting + self._tree_view.setItemDelegateForColumn( + 2, _SettingValueDelegate(self._tree_view) + ) + splitter.setStretchFactor(1, 1) # property table expands splitter.setStretchFactor(2, 1) # tree view expands From 3b599d790f9d5610e4c48c35540c79a3879e6fbe Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Jun 2025 16:55:49 -0400 Subject: [PATCH 08/70] refactor: improve duplicate group handling and name validation in ConfigTreeModel --- example.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/example.py b/example.py index 2bc7bb2d9..28a31771a 100644 --- a/example.py +++ b/example.py @@ -105,8 +105,8 @@ def add_group(self, base_name: str = "Group") -> QModelIndex: def duplicate_group(self, idx: QModelIndex) -> QModelIndex: if not self._is_group_index(idx): return QModelIndex() - orig = cast("_Node", idx.internalPointer()) - new_grp = deepcopy(orig.payload) + node = cast("_Node", idx.internalPointer()) + new_grp = deepcopy(node.payload) assert isinstance(new_grp, ConfigGroup) new_grp.name = self._unique_child_name(self._root, new_grp.name) node = _Node(new_grp.name, new_grp, self._root) @@ -279,8 +279,12 @@ def setData( new_name = cast("str", value) if new_name == node.name: return True + if not new_name: + return False if self._name_exists(node.parent, new_name): - self._show_dup_name_error(new_name) + QMessageBox.warning( + None, "Duplicate name", f"Name '{new_name}' already exists." + ) return False node.name = new_name if node.payload is not None: @@ -372,10 +376,6 @@ def _unique_child_name(parent: _Node, base: str) -> str: def _name_exists(parent: _Node | None, name: str) -> bool: return parent is not None and any(c.name == name for c in parent.children) - @staticmethod - def _show_dup_name_error(name: str) -> None: - QMessageBox.warning(None, "Duplicate name", f"Name '{name}' already exists.") - # convenience guards ------------------------------------------------------ @staticmethod From 66d2d8b97c07b5c790993ca625f1cea73e150735 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Jun 2025 17:17:07 -0400 Subject: [PATCH 09/70] move example --- example.py | 733 +----------------- src/pymmcore_widgets/__init__.py | 2 + .../config_presets/_qmodel/__init__.py | 0 .../config_presets/_qmodel/_config_model.py | 444 +++++++++++ .../config_presets/_qmodel/_config_views.py | 275 +++++++ 5 files changed, 755 insertions(+), 699 deletions(-) create mode 100644 src/pymmcore_widgets/config_presets/_qmodel/__init__.py create mode 100644 src/pymmcore_widgets/config_presets/_qmodel/_config_model.py create mode 100644 src/pymmcore_widgets/config_presets/_qmodel/_config_views.py diff --git a/example.py b/example.py index 28a31771a..cee5980c0 100644 --- a/example.py +++ b/example.py @@ -1,703 +1,38 @@ -from __future__ import annotations - -from contextlib import suppress -from copy import deepcopy -from typing import TYPE_CHECKING, Any, Callable, cast - from pymmcore_plus import CMMCorePlus from pymmcore_plus.model import ConfigGroup, ConfigPreset, Setting -from PyQt6.QtCore import ( - QAbstractItemModel, - QModelIndex, - Qt, - pyqtSignal, +from qtpy.QtWidgets import QApplication + +from pymmcore_widgets import ConfigGroupsEditor + +app = QApplication([]) +core = CMMCorePlus() +core.loadSystemConfiguration() + +# sample config ---------------------------------------------------------- +cam_grp = ConfigGroup( + "Camera", + presets={ + "Cy5": ConfigPreset( + name="Cy5", + settings=[ + Setting("Dichroic", "Label", "400DCLP"), + Setting("Camera", "Gain", "0"), + Setting("Core", "Shutter", "White Light Shutter"), + ], + ), + "FITC": ConfigPreset( + name="FITC", + settings=[ + Setting("Dichroic", "Label", "400DCLP"), + Setting("Emission", "Label", "Chroma-HQ620"), + ], + ), + }, ) -from PyQt6.QtGui import QFont, QIcon -from PyQt6.QtWidgets import ( - QApplication, - QHBoxLayout, - QListView, - QMessageBox, - QSplitter, - QStyledItemDelegate, - QStyleOptionViewItem, - QToolBar, - QTreeView, - QVBoxLayout, - QWidget, -) -from superqt import QIconifyIcon - -from pymmcore_widgets._icons import ICONS -from pymmcore_widgets.device_properties import DevicePropertyTable -from pymmcore_widgets.device_properties._property_widget import PropertyWidget - -if TYPE_CHECKING: - from collections.abc import Iterable, Sequence - - -# ----------------------------------------------------------------------------- -# Internal tree node helper -# ----------------------------------------------------------------------------- - - -class _Node: - """Generic tree node that wraps a ConfigGroup or ConfigPreset.""" - - 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) - - -# ----------------------------------------------------------------------------- -# ConfigTreeModel -# ----------------------------------------------------------------------------- - - -class _ConfigTreeModel(QAbstractItemModel): - """Three-level model: root → groups → presets → settings.""" - - def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: - super().__init__() - self._root = _Node("") - if groups: - self._build_tree(groups) - - # ------------------------------------------------------------------ - # Public helpers used by the widget toolbar actions - # ------------------------------------------------------------------ - - # group-level ------------------------------------------------------------- - - def add_group(self, base_name: str = "Group") -> QModelIndex: - """Append a *new* empty group and return its QModelIndex.""" - name = self._unique_child_name(self._root, base_name) - group = ConfigGroup(name) - node = _Node(name, group, self._root) - return self._insert_node(node, self._root, len(self._root.children)) - - def duplicate_group(self, idx: QModelIndex) -> QModelIndex: - if not self._is_group_index(idx): - return QModelIndex() - node = cast("_Node", idx.internalPointer()) - new_grp = deepcopy(node.payload) - assert isinstance(new_grp, ConfigGroup) - new_grp.name = self._unique_child_name(self._root, new_grp.name) - node = _Node(new_grp.name, new_grp, self._root) - # duplicate presets - for p in new_grp.presets.values(): - child_node = _Node(p.name, p, node) - node.children.append(child_node) - return self._insert_node(node, self._root, idx.row() + 1) - - # preset-level ------------------------------------------------------------ - - def add_preset( - self, group_idx: QModelIndex, base_name: str = "Preset" - ) -> QModelIndex: - if not self._is_group_index(group_idx): - return QModelIndex() - parent_node = cast("_Node", group_idx.internalPointer()) - name = self._unique_child_name(parent_node, base_name) - preset = ConfigPreset(name) - node = _Node(name, preset, parent_node) - return self._insert_node(node, parent_node, len(parent_node.children)) - - def duplicate_preset(self, idx: QModelIndex) -> QModelIndex: - if not self._is_preset_index(idx): - return QModelIndex() - parent_node = cast("_Node", idx.parent().internalPointer()) - orig = cast("_Node", idx.internalPointer()) - new_preset = deepcopy(orig.payload) - assert isinstance(new_preset, ConfigPreset) - new_preset.name = self._unique_child_name(parent_node, new_preset.name) - node = _Node(new_preset.name, new_preset, parent_node) - return self._insert_node(node, parent_node, idx.row() + 1) - - # generic remove ---------------------------------------------------------- - - def remove(self, idx: QModelIndex) -> None: - if not idx.isValid(): - return - node = cast("_Node", idx.internalPointer()) - parent_node = node.parent - if parent_node is None: - return - self.beginRemoveRows(idx.parent(), idx.row(), idx.row()) - parent_node.children.pop(idx.row()) - self.endRemoveRows() - - # ------------------------------------------------------------------ - # Required Qt model overrides - # ------------------------------------------------------------------ - - # structure helpers ------------------------------------------------------- - - def _node(self, index: QModelIndex | None) -> _Node: - return ( - cast("_Node", index.internalPointer()) - if index and index.isValid() - else self._root - ) - - def rowCount(self, parent: QModelIndex | None = None) -> int: - return len(self._node(parent).children) - - def columnCount(self, _parent: QModelIndex | None = None) -> int: - return 3 - - def index( - self, row: int, column: int = 0, parent: QModelIndex | None = None - ) -> QModelIndex: - parent_node = self._node(parent) - if 0 <= row < len(parent_node.children): - return self.createIndex(row, column, parent_node.children[row]) - return QModelIndex() - - def parent(self, child: QModelIndex) -> QModelIndex: # type: ignore[override] - """Returns the parent of the model item with the given index. - - If the item has no parent, an invalid QModelIndex is returned. - """ - if not child or not child.isValid(): - return QModelIndex() - parent_node = cast("_Node", child.internalPointer()).parent - if parent_node is self._root or parent_node is None: - return QModelIndex() - return self.createIndex(parent_node.row_in_parent(), 0, parent_node) - - # data & editing ---------------------------------------------------------- - - def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: - if not index.isValid(): - return None - - node = cast("_Node", index.internalPointer()) - if role == Qt.ItemDataRole.FontRole and index.column() == 0: - f = QFont() - if node.is_group: - f.setBold(True) - elif node.is_preset: - f.setItalic(True) - return f - - if role == Qt.ItemDataRole.DecorationRole and index.column() == 0: - if node.is_group: - return QIcon.fromTheme("folder") - if node.is_preset: - return QIcon.fromTheme("document") - if node.is_setting: - setting = cast("Setting", node.payload) - with suppress(Exception): - dtype = CMMCorePlus.instance().getDeviceType(setting.device_name) - if icon_string := ICONS.get(dtype): - return QIconifyIcon(icon_string, color="gray").pixmap(16, 16) - - return QIcon.fromTheme("emblem-system") - - if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - # settings: show Device, Property, Value - if node.is_setting: - setting: Setting = cast("Setting", node.payload) - if index.column() == 0: - return setting.device_name - if index.column() == 1: - return setting.property_name - if index.column() == 2: - return setting.property_value - return None - - # groups / presets: only show name - elif index.column() == 0: - return node.name - return None - - def flags(self, index: QModelIndex) -> Qt.ItemFlag: - if not index.isValid(): - return Qt.ItemFlag.NoItemFlags - fl = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled - node = cast("_Node", index.internalPointer()) - if node.is_setting and index.column() == 2: - fl |= Qt.ItemFlag.ItemIsEditable - elif not node.is_setting: - fl |= Qt.ItemFlag.ItemIsEditable - return fl - - def setData( - self, - index: QModelIndex, - value: Any, - role: int = Qt.ItemDataRole.EditRole, - ) -> bool: - if role != Qt.ItemDataRole.EditRole or not index.isValid(): - return False - node = cast("_Node", index.internalPointer()) - if node.is_setting and index.column() == 2: - setting = cast("Setting", node.payload) - setting = Setting( - setting.device_name, setting.property_name, cast("str", value) - ) - node.payload = setting - # also update the preset.settings list reference - # find node.parent.payload (ConfigPreset) and update list element - parent_preset = cast("ConfigPreset", node.parent.payload) - for i, s in enumerate(parent_preset.settings): - if ( - s.device_name == setting.device_name - and s.property_name == setting.property_name - ): - parent_preset.settings[i] = setting - break - self.dataChanged.emit(index, index, [role]) - return True - new_name = cast("str", value) - if new_name == node.name: - return True - if not new_name: - 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 - if node.payload is not None: - node.payload.name = new_name # keep dataclass in sync - self.dataChanged.emit(index, index, [role]) - return True - - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ - - def _build_tree(self, groups: Iterable[ConfigGroup]) -> None: - self._root.children.clear() - for g in groups: - gnode = _Node(g.name, g, self._root) - self._root.children.append(gnode) - for p in g.presets.values(): - pnode = _Node(p.name, p, gnode) - gnode.children.append(pnode) - # add one child per Setting - for s in p.settings: - snode = _Node(s.device_name, s, pnode) - pnode.children.append(snode) - - # ------------------------------------------------------------------ - # Public mutator helpers - # ------------------------------------------------------------------ - - def update_preset_settings( - self, preset_idx: QModelIndex, settings: list[Setting] - ) -> None: - """Replace settings and update the tree safely. - - We remove old Setting rows with beginRemoveRows/endRemoveRows, - then insert the new ones. This guarantees attached views drop any - QModelIndex that referenced the old child nodes (avoiding the crash - seen when switching presets). - """ - if not self._is_preset_index(preset_idx): - return - - preset_node = cast("_Node", preset_idx.internalPointer()) - preset: ConfigPreset = cast("ConfigPreset", preset_node.payload) - - # --- mutate underlying dataclass ---------------------------------- - preset.settings = list(settings) - - # --- remove existing Setting rows --------------------------------- - old_row_count = len(preset_node.children) - if old_row_count: - self.beginRemoveRows(preset_idx, 0, old_row_count - 1) - preset_node.children.clear() - self.endRemoveRows() - - # --- insert new Setting rows -------------------------------------- - new_row_count = len(settings) - if new_row_count: - self.beginInsertRows(preset_idx, 0, new_row_count - 1) - for s in settings: - preset_node.children.append(_Node(s.device_name, s, preset_node)) - self.endInsertRows() - - def headerData( - self, - section: int, - orientation: Qt.Orientation, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if ( - orientation == Qt.Orientation.Horizontal - and role == Qt.ItemDataRole.DisplayRole - ): - return ["Item", "Property", "Value"][section] if 0 <= section < 3 else None - return super().headerData(section, orientation, role) - - # name uniqueness --------------------------------------------------------- - - @staticmethod - def _unique_child_name(parent: _Node, base: str) -> str: - names = {c.name for c in parent.children} - if base not in names: - return base - i = 1 - while f"{base} {i}" in names: - i += 1 - return f"{base} {i}" - - @staticmethod - def _name_exists(parent: _Node | None, name: str) -> bool: - return parent is not None and any(c.name == name for c in parent.children) - - # convenience guards ------------------------------------------------------ - - @staticmethod - def _is_group_index(idx: QModelIndex) -> bool: - return idx.isValid() and cast("_Node", idx.internalPointer()).is_group - - @staticmethod - def _is_preset_index(idx: QModelIndex) -> bool: - return idx.isValid() and cast("_Node", idx.internalPointer()).is_preset - - # insertion --------------------------------------------------------------- - - def _insert_node(self, node: _Node, parent_node: _Node, row: int) -> QModelIndex: - self.beginInsertRows(self._index_from_node(parent_node), row, row) - parent_node.children.insert(row, node) - self.endInsertRows() - return self.createIndex(row, 0, node) - - def _index_from_node(self, node: _Node) -> QModelIndex: - if node is self._root: - return QModelIndex() - return self.createIndex(node.row_in_parent(), 0, node) - - # external data API ------------------------------------------------------- - - def set_groups(self, groups: Iterable[ConfigGroup]) -> None: - self.beginResetModel() - self._build_tree(groups) - self.endResetModel() - - def data_as_groups(self) -> list[ConfigGroup]: - """Return a *deep copy* of current configuration as dataclasses.""" - return deepcopy([cast("ConfigGroup", n.payload) for n in self._root.children]) - - -# ----------------------------------------------------------------------------- -# Property table placeholder (unchanged) -# ----------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# Delegate: always use QLineEdit for a Setting's value cell (column 2) -# --------------------------------------------------------------------------- -class _SettingValueDelegate(QStyledItemDelegate): - """Provide a plain QLineEdit for editing Setting value cells.""" - - def createEditor( - self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex - ) -> QWidget | None: - node = cast("_Node", index.internalPointer()) - if not (model := index.model()) or (index.column() != 2) or not node.is_setting: - return super().createEditor(parent, option, index) - - row = index.row() - - device = model.data(index.sibling(row, 0), Qt.ItemDataRole.DisplayRole) - prop = model.data(index.sibling(row, 1), Qt.ItemDataRole.DisplayRole) - widget = PropertyWidget(device, prop, parent=parent, connect_core=False) - widget.valueChanged.connect(lambda: self.commitData.emit(widget)) - widget.setAutoFillBackground(True) - return widget - - def setEditorData(self, editor: QWidget, index: QModelIndex) -> None: - if (model := index.model()) and isinstance(editor, PropertyWidget): - data = model.data(index, Qt.ItemDataRole.EditRole) - editor.setValue(data) - else: - super().setEditorData(editor, index) - - def setModelData( - self, editor: QWidget, model: QAbstractItemModel, index: QModelIndex - ) -> None: - if isinstance(editor, PropertyWidget): - model.setData(index, editor.value(), Qt.ItemDataRole.EditRole) - else: - super().setModelData(editor, model, index) - - -# ----------------------------------------------------------------------------- -# High-level editor widget -# ----------------------------------------------------------------------------- - - -class ConfigEditor(QWidget): - """Widget composed of two QListViews backed by a single tree model.""" - - configChanged = pyqtSignal() - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self._model = _ConfigTreeModel() - - # views -------------------------------------------------------------- - self._group_view = QListView() - self._group_view.setModel(self._model) - self._group_view.setSelectionMode(QListView.SelectionMode.SingleSelection) - - self._preset_view = QListView() - self._preset_view.setModel(self._model) - self._preset_view.setSelectionMode(QListView.SelectionMode.SingleSelection) - - # toolbars ----------------------------------------------------------- - self._group_tb = self._make_tb(self._new_group, self._remove, self._dup_group) - self._preset_tb = self._make_tb( - self._new_preset, self._remove, self._dup_preset - ) - - # layout ------------------------------------------------------------- - left = QWidget() - lv = QVBoxLayout(left) - lv.setContentsMargins(0, 0, 0, 0) - lv.addWidget(self._group_tb) - lv.addWidget(self._group_view) - lv.addWidget(self._preset_tb) - lv.addWidget(self._preset_view) - - splitter = QSplitter() - # left-hand panel - splitter.addWidget(left) - - # center placeholder property table - self._prop_table = DevicePropertyTable() - self._prop_table.setRowsCheckable(True) - self._prop_table.filterDevices(include_pre_init=False, include_read_only=False) - self._prop_table.valueChanged.connect(self._on_prop_table_changed) - splitter.addWidget(self._prop_table) - - # right-hand tree view showing the *same* model - self._tree_view = QTreeView() - self._tree_view.setModel(self._model) - self._tree_view.expandAll() # helpful for the demo - splitter.addWidget(self._tree_view) - - # column 2 (Value) uses a line-edit when editing a Setting - self._tree_view.setItemDelegateForColumn( - 2, _SettingValueDelegate(self._tree_view) - ) - - splitter.setStretchFactor(1, 1) # property table expands - splitter.setStretchFactor(2, 1) # tree view expands - - lay = QHBoxLayout(self) - lay.setContentsMargins(0, 0, 0, 0) - lay.addWidget(splitter) - - # signals ------------------------------------------------------------ - if sm := self._group_view.selectionModel(): - sm.currentChanged.connect(self._on_group_sel) - if sm := self._preset_view.selectionModel(): - sm.currentChanged.connect(self._on_preset_sel) - self._model.dataChanged.connect(self._on_model_data_changed) - - # ------------------------------------------------------------------ - # Public API required by spec - # ------------------------------------------------------------------ - - def setData(self, data: Iterable[ConfigGroup]) -> None: - """Set the configuration data to be displayed in the editor.""" - self._model.set_groups(data) - self._prop_table.setValue([]) - # Auto-select first group - if self._model.rowCount(): - self._group_view.setCurrentIndex(self._model.index(0)) - else: - self._preset_view.setRootIndex(QModelIndex()) - self._preset_view.clearSelection() - self.configChanged.emit() - - def data(self) -> Sequence[ConfigGroup]: - """Return the current configuration data as a list of ConfigGroup.""" - return self._model.data_as_groups() - - # ------------------------------------------------------------------ - # Toolbar action helpers - # ------------------------------------------------------------------ - - @staticmethod - def _make_tb(new_fn: Callable, rem_fn: Callable, dup_fn: Callable) -> QToolBar: - tb = QToolBar() - tb.addAction("New", new_fn) - tb.addAction("Remove", rem_fn) - tb.addAction("Duplicate", dup_fn) - return tb - - def _current_group_index(self) -> QModelIndex: - return self._group_view.currentIndex() - - def _current_preset_index(self) -> QModelIndex: - return self._preset_view.currentIndex() - - # group actions ---------------------------------------------------------- - - def _new_group(self) -> None: - idx = self._model.add_group() - self._group_view.setCurrentIndex(idx) - - def _dup_group(self) -> None: - idx = self._current_group_index() - if idx.isValid(): - self._group_view.setCurrentIndex(self._model.duplicate_group(idx)) - - # preset actions --------------------------------------------------------- - - def _new_preset(self) -> None: - gidx = self._current_group_index() - if not gidx.isValid(): - return - pidx = self._model.add_preset(gidx) - self._preset_view.setCurrentIndex(pidx) - - def _dup_preset(self) -> None: - pidx = self._current_preset_index() - if pidx.isValid(): - self._preset_view.setCurrentIndex(self._model.duplicate_preset(pidx)) - - # shared -------------------------------------------------------------- - - def _remove(self) -> None: - # Determine which view called us based on focus - view = self._preset_view if self._preset_view.hasFocus() else self._group_view - idx = view.currentIndex() - self._model.remove(idx) - - # selection sync --------------------------------------------------------- - - def _on_group_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: - self._preset_view.setRootIndex(current) - if current.isValid() and self._model.rowCount(current): - self._preset_view.setCurrentIndex(self._model.index(0, 0, current)) - else: - self._preset_view.clearSelection() - - # ------------------------------------------------------------------ - # Property-table sync - # ------------------------------------------------------------------ - - def _on_preset_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: - """Populate the DevicePropertyTable whenever the selected preset changes.""" - if not current.isValid(): - # clear table when nothing is selected - self._prop_table.setValue([]) - return - node = cast("_Node", current.internalPointer()) - if not node.is_preset: - self._prop_table.setValue([]) - return - preset = cast("ConfigPreset", node.payload) - self._prop_table.setValue(preset.settings) - - def _on_prop_table_changed(self) -> None: - """Write back edits from the table into the underlying ConfigPreset.""" - idx = self._current_preset_index() - if not idx.isValid(): - return - node = cast("_Node", idx.internalPointer()) - if not node.is_preset: - return - new_settings = self._prop_table.value() - self._model.update_preset_settings(idx, new_settings) - self.configChanged.emit() - - def _on_model_data_changed( - self, - topLeft: QModelIndex, - bottomRight: QModelIndex, - _roles: list[int] | None = None, - ) -> None: - """Refresh DevicePropertyTable if a setting in the current preset was edited.""" - cur_preset = self._current_preset_index() - if not cur_preset.isValid(): - return - - # We only care about edits to rows that are direct children of the - # currently-selected preset (i.e. Setting rows). - if topLeft.parent() != cur_preset: - return - - # pull updated settings from the model and push to the table - node = cast("_Node", cur_preset.internalPointer()) - preset = cast("ConfigPreset", node.payload) - self._prop_table.blockSignals(True) # avoid feedback loop - self._prop_table.setValue(preset.settings) - self._prop_table.blockSignals(False) - - -# ----------------------------------------------------------------------------- -# Demo -# ----------------------------------------------------------------------------- - - -if __name__ == "__main__": - import sys - - app = QApplication(sys.argv) - core = CMMCorePlus() - core.loadSystemConfiguration() - - # sample config ---------------------------------------------------------- - cam_grp = ConfigGroup( - "Camera", - presets={ - "Cy5": ConfigPreset( - name="Cy5", - settings=[ - Setting("Dichroic", "Label", "400DCLP"), - Setting("Camera", "Gain", "0"), - Setting("Core", "Shutter", "White Light Shutter"), - ], - ), - "FITC": ConfigPreset( - name="FITC", - settings=[ - Setting("Dichroic", "Label", "400DCLP"), - Setting("Emission", "Label", "Chroma-HQ620"), - ], - ), - }, - ) - obj_grp = ConfigGroup("Objective") +obj_grp = ConfigGroup("Objective") - w = ConfigEditor() - w.setData([cam_grp, obj_grp]) - w.resize(1200, 600) - w.show() - sys.exit(app.exec()) +w = ConfigGroupsEditor() +w.setData([cam_grp, obj_grp]) +w.resize(1200, 600) +w.show() +app.exec() diff --git a/src/pymmcore_widgets/__init__.py b/src/pymmcore_widgets/__init__.py index 0b27cde9d..cea259fa7 100644 --- a/src/pymmcore_widgets/__init__.py +++ b/src/pymmcore_widgets/__init__.py @@ -14,6 +14,7 @@ "ChannelGroupWidget", "ChannelTable", "ChannelWidget", + "ConfigGroupsEditor", "ConfigWizard", "ConfigurationWidget", "CoreLogWidget", @@ -52,6 +53,7 @@ ObjectivesPixelConfigurationWidget, PixelConfigurationWidget, ) +from .config_presets._qmodel._config_views import ConfigGroupsEditor from .control import ( CameraRoiWidget, ChannelGroupWidget, diff --git a/src/pymmcore_widgets/config_presets/_qmodel/__init__.py b/src/pymmcore_widgets/config_presets/_qmodel/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py new file mode 100644 index 000000000..09f383dcb --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py @@ -0,0 +1,444 @@ +from __future__ import annotations + +from contextlib import suppress +from copy import deepcopy +from enum import IntEnum +from typing import TYPE_CHECKING, Any, cast + +from pymmcore_plus import CMMCorePlus +from pymmcore_plus.model import ConfigGroup, ConfigPreset, Setting +from PyQt6.QtCore import QAbstractItemModel, QModelIndex, Qt +from PyQt6.QtGui import QFont, QIcon +from PyQt6.QtWidgets import ( + QMessageBox, + QStyledItemDelegate, + QStyleOptionViewItem, + QWidget, +) +from superqt import QIconifyIcon + +from pymmcore_widgets._icons import ICONS +from pymmcore_widgets.device_properties._property_widget import PropertyWidget + +if TYPE_CHECKING: + from collections.abc import Iterable + + +class Col(IntEnum): + """Column indices for the ConfigTreeModel.""" + + Item = 0 + Property = 1 + Value = 2 + + +class _Node: + """Generic tree node that wraps a ConfigGroup or ConfigPreset.""" + + 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 ConfigTreeModel(QAbstractItemModel): + """Three-level model: root → groups → presets → settings.""" + + def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: + super().__init__() + self._root = _Node("") + if groups: + self._build_tree(groups) + + # ------------------------------------------------------------------ + # Public helpers used by the widget toolbar actions + # ------------------------------------------------------------------ + + # group-level ------------------------------------------------------------- + + def add_group(self, base_name: str = "Group") -> QModelIndex: + """Append a *new* empty group and return its QModelIndex.""" + name = self._unique_child_name(self._root, base_name) + group = ConfigGroup(name) + node = _Node(name, group, self._root) + return self._insert_node(node, self._root, len(self._root.children)) + + def duplicate_group(self, idx: QModelIndex) -> QModelIndex: + if not self._is_group_index(idx): + return QModelIndex() + node = cast("_Node", idx.internalPointer()) + new_grp = deepcopy(node.payload) + assert isinstance(new_grp, ConfigGroup) + new_grp.name = self._unique_child_name(self._root, new_grp.name) + node = _Node(new_grp.name, new_grp, self._root) + # duplicate presets + for p in new_grp.presets.values(): + child_node = _Node(p.name, p, node) + node.children.append(child_node) + return self._insert_node(node, self._root, idx.row() + 1) + + # preset-level ------------------------------------------------------------ + + def add_preset( + self, group_idx: QModelIndex, base_name: str = "Preset" + ) -> QModelIndex: + if not self._is_group_index(group_idx): + return QModelIndex() + parent_node = cast("_Node", group_idx.internalPointer()) + name = self._unique_child_name(parent_node, base_name) + preset = ConfigPreset(name) + node = _Node(name, preset, parent_node) + return self._insert_node(node, parent_node, len(parent_node.children)) + + def duplicate_preset(self, idx: QModelIndex) -> QModelIndex: + if not self._is_preset_index(idx): + return QModelIndex() + parent_node = cast("_Node", idx.parent().internalPointer()) + orig = cast("_Node", idx.internalPointer()) + new_preset = deepcopy(orig.payload) + assert isinstance(new_preset, ConfigPreset) + new_preset.name = self._unique_child_name(parent_node, new_preset.name) + node = _Node(new_preset.name, new_preset, parent_node) + return self._insert_node(node, parent_node, idx.row() + 1) + + # generic remove ---------------------------------------------------------- + + def remove(self, idx: QModelIndex) -> None: + if not idx.isValid(): + return + node = cast("_Node", idx.internalPointer()) + parent_node = node.parent + if parent_node is None: + return + self.beginRemoveRows(idx.parent(), idx.row(), idx.row()) + parent_node.children.pop(idx.row()) + self.endRemoveRows() + + # ------------------------------------------------------------------ + # Required Qt model overrides + # ------------------------------------------------------------------ + + # structure helpers ------------------------------------------------------- + + def _node(self, index: QModelIndex | None) -> _Node: + return ( + cast("_Node", index.internalPointer()) + if index and index.isValid() + else self._root + ) + + def rowCount(self, parent: QModelIndex | None = None) -> int: + return len(self._node(parent).children) + + def columnCount(self, _parent: QModelIndex | None = None) -> int: + return len(Col) + + def index( + self, row: int, column: int = 0, parent: QModelIndex | None = None + ) -> QModelIndex: + parent_node = self._node(parent) + if 0 <= row < len(parent_node.children): + return self.createIndex(row, column, parent_node.children[row]) + return QModelIndex() + + def parent(self, child: QModelIndex) -> QModelIndex: # type: ignore[override] + """Returns the parent of the model item with the given index. + + If the item has no parent, an invalid QModelIndex is returned. + """ + if not child or not child.isValid(): + return QModelIndex() + parent_node = cast("_Node", child.internalPointer()).parent + if parent_node is self._root or parent_node is None: + return QModelIndex() + return self.createIndex(parent_node.row_in_parent(), 0, parent_node) + + # data & editing ---------------------------------------------------------- + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid(): + return None + + node = cast("_Node", index.internalPointer()) + if role == Qt.ItemDataRole.FontRole and index.column() == Col.Item: + f = QFont() + if node.is_group: + f.setBold(True) + return f + + if role == Qt.ItemDataRole.DecorationRole and index.column() == Col.Item: + if node.is_group: + return QIcon.fromTheme("folder") + if node.is_preset: + return QIcon.fromTheme("document") + if node.is_setting: + setting = cast("Setting", node.payload) + with suppress(Exception): + dtype = CMMCorePlus.instance().getDeviceType(setting.device_name) + if icon_string := ICONS.get(dtype): + return QIconifyIcon(icon_string, color="gray").pixmap(16, 16) + + return QIcon.fromTheme("emblem-system") + + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): + # settings: show Device, Property, Value + if node.is_setting: + setting: Setting = cast("Setting", node.payload) + if index.column() == Col.Item: + return setting.device_name + if index.column() == Col.Property: + return setting.property_name + if index.column() == Col.Value: + return setting.property_value + return None + + # groups / presets: only show name + elif index.column() == Col.Item: + return node.name + return None + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + if not index.isValid(): + return Qt.ItemFlag.NoItemFlags + fl = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled + node = cast("_Node", index.internalPointer()) + if node.is_setting and index.column() == Col.Value: + fl |= Qt.ItemFlag.ItemIsEditable + elif not node.is_setting and index.column() == Col.Item: + fl |= Qt.ItemFlag.ItemIsEditable + return fl + + def setData( + self, + index: QModelIndex, + value: Any, + role: int = Qt.ItemDataRole.EditRole, + ) -> bool: + if role != Qt.ItemDataRole.EditRole or not index.isValid(): + return False + node = cast("_Node", index.internalPointer()) + if node.is_setting and index.column() == Col.Value: + setting = cast("Setting", node.payload) + setting = Setting( + setting.device_name, setting.property_name, cast("str", value) + ) + node.payload = setting + # also update the preset.settings list reference + # find node.parent.payload (ConfigPreset) and update list element + parent_preset = cast("ConfigPreset", node.parent.payload) + for i, s in enumerate(parent_preset.settings): + if ( + s.device_name == setting.device_name + and s.property_name == setting.property_name + ): + parent_preset.settings[i] = setting + break + self.dataChanged.emit(index, index, [role]) + return True + new_name = cast("str", value) + if new_name == node.name: + return True + if not new_name: + 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 + if node.payload is not None: + node.payload.name = new_name # keep dataclass in sync + self.dataChanged.emit(index, index, [role]) + return True + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _build_tree(self, groups: Iterable[ConfigGroup]) -> None: + self._root.children.clear() + for g in groups: + gnode = _Node(g.name, g, self._root) + self._root.children.append(gnode) + for p in g.presets.values(): + pnode = _Node(p.name, p, gnode) + gnode.children.append(pnode) + # add one child per Setting + for s in p.settings: + snode = _Node(s.device_name, s, pnode) + pnode.children.append(snode) + + # ------------------------------------------------------------------ + # Public mutator helpers + # ------------------------------------------------------------------ + + def update_preset_settings( + self, preset_idx: QModelIndex, settings: list[Setting] + ) -> None: + """Replace settings and update the tree safely. + + We remove old Setting rows with beginRemoveRows/endRemoveRows, + then insert the new ones. This guarantees attached views drop any + QModelIndex that referenced the old child nodes (avoiding the crash + seen when switching presets). + """ + if not self._is_preset_index(preset_idx): + return + + preset_node = cast("_Node", preset_idx.internalPointer()) + preset: ConfigPreset = cast("ConfigPreset", preset_node.payload) + + # --- mutate underlying dataclass ---------------------------------- + preset.settings = list(settings) + + # --- remove existing Setting rows --------------------------------- + old_row_count = len(preset_node.children) + if old_row_count: + self.beginRemoveRows(preset_idx, 0, old_row_count - 1) + preset_node.children.clear() + self.endRemoveRows() + + # --- insert new Setting rows -------------------------------------- + new_row_count = len(settings) + if new_row_count: + self.beginInsertRows(preset_idx, 0, new_row_count - 1) + for s in settings: + preset_node.children.append(_Node(s.device_name, s, preset_node)) + self.endInsertRows() + + def headerData( + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> Any: + if ( + orientation == Qt.Orientation.Horizontal + and role == Qt.ItemDataRole.DisplayRole + ): + return Col(section).name if section < len(Col) else None + return super().headerData(section, orientation, role) + + # name uniqueness --------------------------------------------------------- + + @staticmethod + def _unique_child_name(parent: _Node, base: str) -> str: + names = {c.name for c in parent.children} + if base not in names: + return base + i = 1 + while f"{base} {i}" in names: + i += 1 + return f"{base} {i}" + + @staticmethod + def _name_exists(parent: _Node | None, name: str) -> bool: + return parent is not None and any(c.name == name for c in parent.children) + + # convenience guards ------------------------------------------------------ + + @staticmethod + def _is_group_index(idx: QModelIndex) -> bool: + return idx.isValid() and cast("_Node", idx.internalPointer()).is_group + + @staticmethod + def _is_preset_index(idx: QModelIndex) -> bool: + return idx.isValid() and cast("_Node", idx.internalPointer()).is_preset + + # insertion --------------------------------------------------------------- + + def _insert_node(self, node: _Node, parent_node: _Node, row: int) -> QModelIndex: + self.beginInsertRows(self._index_from_node(parent_node), row, row) + parent_node.children.insert(row, node) + self.endInsertRows() + return self.createIndex(row, 0, node) + + def _index_from_node(self, node: _Node) -> QModelIndex: + if node is self._root: + return QModelIndex() + return self.createIndex(node.row_in_parent(), 0, node) + + # external data API ------------------------------------------------------- + + def set_groups(self, groups: Iterable[ConfigGroup]) -> None: + self.beginResetModel() + self._build_tree(groups) + self.endResetModel() + + def data_as_groups(self) -> list[ConfigGroup]: + """Return a *deep copy* of current configuration as dataclasses.""" + return deepcopy([cast("ConfigGroup", n.payload) for n in self._root.children]) + + +# ----------------------------------------------------------------------------- +# Property table placeholder (unchanged) +# ----------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Delegate: always use QLineEdit for a Setting's value cell (column 2) +# --------------------------------------------------------------------------- +class SettingValueDelegate(QStyledItemDelegate): + """Item delegate that uses a PropertyWidget for editing PropertySetting values.""" + + def createEditor( + self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex + ) -> QWidget | None: + node = cast("_Node", index.internalPointer()) + if ( + not (model := index.model()) + or (index.column() != Col.Value) + or not node.is_setting + ): + return super().createEditor(parent, option, index) + + row = index.row() + device = model.data(index.sibling(row, Col.Item)) + prop = model.data(index.sibling(row, Col.Property)) + widget = PropertyWidget(device, prop, parent=parent, connect_core=False) + widget.valueChanged.connect(lambda: self.commitData.emit(widget)) + widget.setAutoFillBackground(True) + return widget + + def setEditorData(self, editor: QWidget | None, index: QModelIndex) -> None: + if (model := index.model()) and isinstance(editor, PropertyWidget): + data = model.data(index, Qt.ItemDataRole.EditRole) + editor.setValue(data) + else: + super().setEditorData(editor, index) + + def setModelData( + self, + editor: QWidget | None, + model: QAbstractItemModel | None, + index: QModelIndex, + ) -> None: + if model and isinstance(editor, PropertyWidget): + model.setData(index, editor.value(), Qt.ItemDataRole.EditRole) + else: + super().setModelData(editor, model, index) diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py new file mode 100644 index 000000000..ae594fc2f --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable, cast + +from PyQt6.QtCore import ( + QAbstractItemModel, + QModelIndex, + Qt, + pyqtSignal, +) +from PyQt6.QtWidgets import ( + QHBoxLayout, + QListView, + QSplitter, + QStyledItemDelegate, + QStyleOptionViewItem, + QToolBar, + QTreeView, + QVBoxLayout, + QWidget, +) + +from pymmcore_widgets.device_properties import DevicePropertyTable +from pymmcore_widgets.device_properties._property_widget import PropertyWidget + +from ._config_model import ConfigTreeModel, _Node + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + from pymmcore_plus.model import ConfigGroup, ConfigPreset + + +class SettingValueDelegate(QStyledItemDelegate): + """Item delegate that uses a PropertyWidget for editing PropertySetting values.""" + + def createEditor( + self, parent: QWidget | None, option: QStyleOptionViewItem, index: QModelIndex + ) -> QWidget | None: + node = cast("_Node", index.internalPointer()) + if not (model := index.model()) or (index.column() != 2) or not node.is_setting: + return super().createEditor(parent, option, index) + + row = index.row() + device = model.data(index.sibling(row, 0)) + prop = model.data(index.sibling(row, 1)) + widget = PropertyWidget(device, prop, parent=parent, connect_core=False) + widget.valueChanged.connect(lambda: self.commitData.emit(widget)) + widget.setAutoFillBackground(True) + return widget + + def setEditorData(self, editor: QWidget | None, index: QModelIndex) -> None: + if (model := index.model()) and isinstance(editor, PropertyWidget): + data = model.data(index, Qt.ItemDataRole.EditRole) + editor.setValue(data) + else: + super().setEditorData(editor, index) + + def setModelData( + self, + editor: QWidget | None, + model: QAbstractItemModel | None, + index: QModelIndex, + ) -> None: + if model and isinstance(editor, PropertyWidget): + model.setData(index, editor.value(), Qt.ItemDataRole.EditRole) + else: + super().setModelData(editor, model, index) + + +# ----------------------------------------------------------------------------- +# High-level editor widget +# ----------------------------------------------------------------------------- + + +class ConfigGroupsEditor(QWidget): + """Widget composed of two QListViews backed by a single tree model.""" + + configChanged = pyqtSignal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._model = ConfigTreeModel() + + # views -------------------------------------------------------------- + self._group_view = QListView() + self._group_view.setModel(self._model) + self._group_view.setSelectionMode(QListView.SelectionMode.SingleSelection) + + self._preset_view = QListView() + self._preset_view.setModel(self._model) + self._preset_view.setSelectionMode(QListView.SelectionMode.SingleSelection) + + # toolbars ----------------------------------------------------------- + self._group_tb = self._make_tb(self._new_group, self._remove, self._dup_group) + self._preset_tb = self._make_tb( + self._new_preset, self._remove, self._dup_preset + ) + + # layout ------------------------------------------------------------- + left = QWidget() + lv = QVBoxLayout(left) + lv.setContentsMargins(0, 0, 0, 0) + lv.addWidget(self._group_tb) + lv.addWidget(self._group_view) + lv.addWidget(self._preset_tb) + lv.addWidget(self._preset_view) + + splitter = QSplitter() + # left-hand panel + splitter.addWidget(left) + + # center placeholder property table + self._prop_table = DevicePropertyTable() + self._prop_table.setRowsCheckable(True) + self._prop_table.filterDevices(include_pre_init=False, include_read_only=False) + self._prop_table.valueChanged.connect(self._on_prop_table_changed) + splitter.addWidget(self._prop_table) + + # right-hand tree view showing the *same* model + self._tree_view = QTreeView() + self._tree_view.setModel(self._model) + self._tree_view.expandAll() # helpful for the demo + splitter.addWidget(self._tree_view) + + # column 2 (Value) uses a line-edit when editing a Setting + self._tree_view.setItemDelegateForColumn( + 2, SettingValueDelegate(self._tree_view) + ) + + splitter.setStretchFactor(1, 1) # property table expands + splitter.setStretchFactor(2, 1) # tree view expands + + lay = QHBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(splitter) + + # signals ------------------------------------------------------------ + if sm := self._group_view.selectionModel(): + sm.currentChanged.connect(self._on_group_sel) + if sm := self._preset_view.selectionModel(): + sm.currentChanged.connect(self._on_preset_sel) + self._model.dataChanged.connect(self._on_model_data_changed) + + # ------------------------------------------------------------------ + # Public API required by spec + # ------------------------------------------------------------------ + + def setData(self, data: Iterable[ConfigGroup]) -> None: + """Set the configuration data to be displayed in the editor.""" + self._model.set_groups(data) + self._prop_table.setValue([]) + # Auto-select first group + if self._model.rowCount(): + self._group_view.setCurrentIndex(self._model.index(0)) + else: + self._preset_view.setRootIndex(QModelIndex()) + self._preset_view.clearSelection() + self.configChanged.emit() + + def data(self) -> Sequence[ConfigGroup]: + """Return the current configuration data as a list of ConfigGroup.""" + return self._model.data_as_groups() + + # ------------------------------------------------------------------ + # Toolbar action helpers + # ------------------------------------------------------------------ + + @staticmethod + def _make_tb(new_fn: Callable, rem_fn: Callable, dup_fn: Callable) -> QToolBar: + tb = QToolBar() + tb.addAction("New", new_fn) + tb.addAction("Remove", rem_fn) + tb.addAction("Duplicate", dup_fn) + return tb + + def _current_group_index(self) -> QModelIndex: + return self._group_view.currentIndex() + + def _current_preset_index(self) -> QModelIndex: + return self._preset_view.currentIndex() + + # group actions ---------------------------------------------------------- + + def _new_group(self) -> None: + idx = self._model.add_group() + self._group_view.setCurrentIndex(idx) + + def _dup_group(self) -> None: + idx = self._current_group_index() + if idx.isValid(): + self._group_view.setCurrentIndex(self._model.duplicate_group(idx)) + + # preset actions --------------------------------------------------------- + + def _new_preset(self) -> None: + gidx = self._current_group_index() + if not gidx.isValid(): + return + pidx = self._model.add_preset(gidx) + self._preset_view.setCurrentIndex(pidx) + + def _dup_preset(self) -> None: + pidx = self._current_preset_index() + if pidx.isValid(): + self._preset_view.setCurrentIndex(self._model.duplicate_preset(pidx)) + + # shared -------------------------------------------------------------- + + def _remove(self) -> None: + # Determine which view called us based on focus + view = self._preset_view if self._preset_view.hasFocus() else self._group_view + idx = view.currentIndex() + self._model.remove(idx) + + # selection sync --------------------------------------------------------- + + def _on_group_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: + self._preset_view.setRootIndex(current) + if current.isValid() and self._model.rowCount(current): + self._preset_view.setCurrentIndex(self._model.index(0, 0, current)) + else: + self._preset_view.clearSelection() + + # ------------------------------------------------------------------ + # Property-table sync + # ------------------------------------------------------------------ + + def _on_preset_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: + """Populate the DevicePropertyTable whenever the selected preset changes.""" + if not current.isValid(): + # clear table when nothing is selected + self._prop_table.setValue([]) + return + node = cast("_Node", current.internalPointer()) + if not node.is_preset: + self._prop_table.setValue([]) + return + preset = cast("ConfigPreset", node.payload) + self._prop_table.setValue(preset.settings) + + def _on_prop_table_changed(self) -> None: + """Write back edits from the table into the underlying ConfigPreset.""" + idx = self._current_preset_index() + if not idx.isValid(): + return + node = cast("_Node", idx.internalPointer()) + if not node.is_preset: + return + new_settings = self._prop_table.value() + self._model.update_preset_settings(idx, new_settings) + self.configChanged.emit() + + def _on_model_data_changed( + self, + topLeft: QModelIndex, + bottomRight: QModelIndex, + _roles: list[int] | None = None, + ) -> None: + """Refresh DevicePropertyTable if a setting in the current preset was edited.""" + cur_preset = self._current_preset_index() + if not cur_preset.isValid(): + return + + # We only care about edits to rows that are direct children of the + # currently-selected preset (i.e. Setting rows). + if topLeft.parent() != cur_preset: + return + + # pull updated settings from the model and push to the table + node = cast("_Node", cur_preset.internalPointer()) + preset = cast("ConfigPreset", node.payload) + self._prop_table.blockSignals(True) # avoid feedback loop + self._prop_table.setValue(preset.settings) + self._prop_table.blockSignals(False) From ba006996a13eac73154817222c9736a0bbbc1258 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Jun 2025 17:19:58 -0400 Subject: [PATCH 10/70] move example --- .../config_presets/_qmodel/_config_model.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py index 09f383dcb..f17e177a7 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py @@ -38,7 +38,7 @@ class _Node: def __init__( self, name: str, - payload: ConfigGroup | ConfigPreset | Setting | None = None, + payload: ConfigGroup | ConfigPreset | Setting, parent: _Node | None = None, ) -> None: self.name = name @@ -166,7 +166,7 @@ def index( return self.createIndex(row, column, parent_node.children[row]) return QModelIndex() - def parent(self, child: QModelIndex) -> QModelIndex: # type: ignore[override] + def parent(self, child: QModelIndex) -> QModelIndex: """Returns the parent of the model item with the given index. If the item has no parent, an invalid QModelIndex is returned. @@ -208,7 +208,7 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): # settings: show Device, Property, Value if node.is_setting: - setting: Setting = cast("Setting", node.payload) + setting = cast("Setting", node.payload) if index.column() == Col.Item: return setting.device_name if index.column() == Col.Property: @@ -271,7 +271,7 @@ def setData( ) return False node.name = new_name - if node.payload is not None: + if isinstance(node.payload, (ConfigGroup, ConfigPreset)): node.payload.name = new_name # keep dataclass in sync self.dataChanged.emit(index, index, [role]) return True From a3f03cb4222f362ca15b81949c4d172e78e53e99 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Jun 2025 18:14:19 -0400 Subject: [PATCH 11/70] updates --- .../config_presets/_qmodel/_config_model.py | 16 ++++++++-------- .../config_presets/_qmodel/_config_views.py | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py index f17e177a7..4acabaaa5 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py @@ -7,9 +7,9 @@ from pymmcore_plus import CMMCorePlus from pymmcore_plus.model import ConfigGroup, ConfigPreset, Setting -from PyQt6.QtCore import QAbstractItemModel, QModelIndex, Qt -from PyQt6.QtGui import QFont, QIcon -from PyQt6.QtWidgets import ( +from qtpy.QtCore import QAbstractItemModel, QModelIndex, Qt +from qtpy.QtGui import QFont, QIcon +from qtpy.QtWidgets import ( QMessageBox, QStyledItemDelegate, QStyleOptionViewItem, @@ -38,7 +38,7 @@ class _Node: def __init__( self, name: str, - payload: ConfigGroup | ConfigPreset | Setting, + payload: ConfigGroup | ConfigPreset | Setting | None = None, parent: _Node | None = None, ) -> None: self.name = name @@ -66,12 +66,12 @@ def is_setting(self) -> bool: return isinstance(self.payload, Setting) -class ConfigTreeModel(QAbstractItemModel): +class QConfigTreeModel(QAbstractItemModel): """Three-level model: root → groups → presets → settings.""" def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: super().__init__() - self._root = _Node("") + self._root = _Node("", None) if groups: self._build_tree(groups) @@ -84,7 +84,7 @@ def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: def add_group(self, base_name: str = "Group") -> QModelIndex: """Append a *new* empty group and return its QModelIndex.""" name = self._unique_child_name(self._root, base_name) - group = ConfigGroup(name) + group = ConfigGroup(name=name) node = _Node(name, group, self._root) return self._insert_node(node, self._root, len(self._root.children)) @@ -250,7 +250,7 @@ def setData( node.payload = setting # also update the preset.settings list reference # find node.parent.payload (ConfigPreset) and update list element - parent_preset = cast("ConfigPreset", node.parent.payload) + parent_preset = cast("ConfigPreset", node.parent.payload) # type: ignore for i, s in enumerate(parent_preset.settings): if ( s.device_name == setting.device_name diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py index ae594fc2f..63832338c 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py @@ -2,13 +2,13 @@ from typing import TYPE_CHECKING, Callable, cast -from PyQt6.QtCore import ( +from qtpy.QtCore import ( QAbstractItemModel, QModelIndex, Qt, - pyqtSignal, + Signal, ) -from PyQt6.QtWidgets import ( +from qtpy.QtWidgets import ( QHBoxLayout, QListView, QSplitter, @@ -23,7 +23,7 @@ from pymmcore_widgets.device_properties import DevicePropertyTable from pymmcore_widgets.device_properties._property_widget import PropertyWidget -from ._config_model import ConfigTreeModel, _Node +from ._config_model import QConfigTreeModel, _Node if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -76,11 +76,11 @@ def setModelData( class ConfigGroupsEditor(QWidget): """Widget composed of two QListViews backed by a single tree model.""" - configChanged = pyqtSignal() + configChanged = Signal() def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) - self._model = ConfigTreeModel() + self._model = QConfigTreeModel() # views -------------------------------------------------------------- self._group_view = QListView() From 05ff08f3fe1823078c9381b515c118bc447d78c7 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 25 Jun 2025 18:56:34 -0400 Subject: [PATCH 12/70] feat: enhance ConfigGroupsEditor with tree view and layout adjustments~ --- example.py | 23 ++- .../config_presets/_qmodel/_config_views.py | 165 ++++++++---------- 2 files changed, 96 insertions(+), 92 deletions(-) diff --git a/example.py b/example.py index cee5980c0..5237152ea 100644 --- a/example.py +++ b/example.py @@ -1,8 +1,9 @@ from pymmcore_plus import CMMCorePlus from pymmcore_plus.model import ConfigGroup, ConfigPreset, Setting -from qtpy.QtWidgets import QApplication +from qtpy.QtWidgets import QApplication, QHBoxLayout, QTreeView, QWidget from pymmcore_widgets import ConfigGroupsEditor +from pymmcore_widgets.config_presets._qmodel._config_views import SettingValueDelegate app = QApplication([]) core = CMMCorePlus() @@ -31,8 +32,22 @@ ) obj_grp = ConfigGroup("Objective") -w = ConfigGroupsEditor() -w.setData([cam_grp, obj_grp]) -w.resize(1200, 600) +cfg = ConfigGroupsEditor() +cfg.setData([cam_grp, obj_grp]) + +# right-hand tree view showing the *same* model +tree = QTreeView() +tree.setModel(cfg._model) +tree.setColumnWidth(0, 160) # column 0 (Name) width +# column 2 (Value) uses a line-edit when editing a Setting +tree.setItemDelegateForColumn(2, SettingValueDelegate(tree)) + + +w = QWidget() +layout = QHBoxLayout(w) +layout.addWidget(cfg) +layout.addWidget(tree) +w.resize(1400, 800) w.show() + app.exec() diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py index 63832338c..9ae73b2bf 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py @@ -2,23 +2,19 @@ from typing import TYPE_CHECKING, Callable, cast -from qtpy.QtCore import ( - QAbstractItemModel, - QModelIndex, - Qt, - Signal, -) +from qtpy.QtCore import QAbstractItemModel, QModelIndex, QSize, Qt, Signal from qtpy.QtWidgets import ( + QGroupBox, QHBoxLayout, QListView, QSplitter, QStyledItemDelegate, QStyleOptionViewItem, QToolBar, - QTreeView, QVBoxLayout, QWidget, ) +from superqt import QIconifyIcon from pymmcore_widgets.device_properties import DevicePropertyTable from pymmcore_widgets.device_properties._property_widget import PropertyWidget @@ -73,6 +69,58 @@ def setModelData( # ----------------------------------------------------------------------------- +class _NameList(QGroupBox): + """A group box that contains a toolbar and a QListView for cfg groups or presets.""" + + def __init__( + self, title: str, parent: QWidget | None, new_fn: Callable, list_view: QListView + ) -> None: + super().__init__(title, parent) + + # toolbar + self._toolbar = QToolBar() + self._toolbar.setIconSize(QSize(18, 18)) + self._toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + + self._toolbar.addAction( + QIconifyIcon("mdi:plus-thick", color="gray"), + f"Add {title.rstrip('s')}", + new_fn, + ) + self._toolbar.addAction( + QIconifyIcon("mdi:remove-bold", color="gray"), + "Remove", + self._remove, + ) + self._toolbar.addAction( + QIconifyIcon("mdi:content-duplicate", color="gray"), + "Duplicate", + self._dupe, + ) + + self._view = list_view + self._model = cast("QConfigTreeModel", list_view.model()) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._toolbar) + layout.addWidget(list_view) + + def _is_groups(self) -> bool: + """Check if this box is for groups.""" + return bool(self.title() == "Groups") + + def _remove(self) -> None: + self._model.remove(self._view.currentIndex()) + + def _dupe(self) -> None: + idx = self._view.currentIndex() + if idx.isValid(): + if self._is_groups(): + self._view.setCurrentIndex(self._model.duplicate_group(idx)) + else: + self._view.setCurrentIndex(self._model.duplicate_preset(idx)) + + class ConfigGroupsEditor(QWidget): """Widget composed of two QListViews backed by a single tree model.""" @@ -82,60 +130,39 @@ def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._model = QConfigTreeModel() - # views -------------------------------------------------------------- + # widgets -------------------------------------------------------------- + self._group_view = QListView() self._group_view.setModel(self._model) - self._group_view.setSelectionMode(QListView.SelectionMode.SingleSelection) + group_box = _NameList("Groups", self, self._new_group, self._group_view) self._preset_view = QListView() self._preset_view.setModel(self._model) - self._preset_view.setSelectionMode(QListView.SelectionMode.SingleSelection) + preset_box = _NameList("Presets", self, self._new_preset, self._preset_view) - # toolbars ----------------------------------------------------------- - self._group_tb = self._make_tb(self._new_group, self._remove, self._dup_group) - self._preset_tb = self._make_tb( - self._new_preset, self._remove, self._dup_preset - ) + self._prop_table = DevicePropertyTable() + self._prop_table.setRowsCheckable(True) + self._prop_table.filterDevices(include_pre_init=False, include_read_only=False) + self._prop_table.valueChanged.connect(self._on_prop_table_changed) + + # layout ------------------------------------------------------------ - # layout ------------------------------------------------------------- left = QWidget() lv = QVBoxLayout(left) lv.setContentsMargins(0, 0, 0, 0) - lv.addWidget(self._group_tb) - lv.addWidget(self._group_view) - lv.addWidget(self._preset_tb) - lv.addWidget(self._preset_view) + lv.addWidget(group_box) + lv.addWidget(preset_box) splitter = QSplitter() - # left-hand panel splitter.addWidget(left) - - # center placeholder property table - self._prop_table = DevicePropertyTable() - self._prop_table.setRowsCheckable(True) - self._prop_table.filterDevices(include_pre_init=False, include_read_only=False) - self._prop_table.valueChanged.connect(self._on_prop_table_changed) splitter.addWidget(self._prop_table) - # right-hand tree view showing the *same* model - self._tree_view = QTreeView() - self._tree_view.setModel(self._model) - self._tree_view.expandAll() # helpful for the demo - splitter.addWidget(self._tree_view) - - # column 2 (Value) uses a line-edit when editing a Setting - self._tree_view.setItemDelegateForColumn( - 2, SettingValueDelegate(self._tree_view) - ) - - splitter.setStretchFactor(1, 1) # property table expands - splitter.setStretchFactor(2, 1) # tree view expands - lay = QHBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.addWidget(splitter) # signals ------------------------------------------------------------ + if sm := self._group_view.selectionModel(): sm.currentChanged.connect(self._on_group_sel) if sm := self._preset_view.selectionModel(): @@ -143,7 +170,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._model.dataChanged.connect(self._on_model_data_changed) # ------------------------------------------------------------------ - # Public API required by spec + # Public API # ------------------------------------------------------------------ def setData(self, data: Iterable[ConfigGroup]) -> None: @@ -162,57 +189,19 @@ def data(self) -> Sequence[ConfigGroup]: """Return the current configuration data as a list of ConfigGroup.""" return self._model.data_as_groups() - # ------------------------------------------------------------------ - # Toolbar action helpers - # ------------------------------------------------------------------ - - @staticmethod - def _make_tb(new_fn: Callable, rem_fn: Callable, dup_fn: Callable) -> QToolBar: - tb = QToolBar() - tb.addAction("New", new_fn) - tb.addAction("Remove", rem_fn) - tb.addAction("Duplicate", dup_fn) - return tb - - def _current_group_index(self) -> QModelIndex: - return self._group_view.currentIndex() - - def _current_preset_index(self) -> QModelIndex: - return self._preset_view.currentIndex() - - # group actions ---------------------------------------------------------- + # "new" actions ---------------------------------------------------------- def _new_group(self) -> None: idx = self._model.add_group() self._group_view.setCurrentIndex(idx) - def _dup_group(self) -> None: - idx = self._current_group_index() - if idx.isValid(): - self._group_view.setCurrentIndex(self._model.duplicate_group(idx)) - - # preset actions --------------------------------------------------------- - def _new_preset(self) -> None: - gidx = self._current_group_index() + gidx = self._group_view.currentIndex() if not gidx.isValid(): return pidx = self._model.add_preset(gidx) self._preset_view.setCurrentIndex(pidx) - def _dup_preset(self) -> None: - pidx = self._current_preset_index() - if pidx.isValid(): - self._preset_view.setCurrentIndex(self._model.duplicate_preset(pidx)) - - # shared -------------------------------------------------------------- - - def _remove(self) -> None: - # Determine which view called us based on focus - view = self._preset_view if self._preset_view.hasFocus() else self._group_view - idx = view.currentIndex() - self._model.remove(idx) - # selection sync --------------------------------------------------------- def _on_group_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: @@ -222,10 +211,6 @@ def _on_group_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: else: self._preset_view.clearSelection() - # ------------------------------------------------------------------ - # Property-table sync - # ------------------------------------------------------------------ - def _on_preset_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: """Populate the DevicePropertyTable whenever the selected preset changes.""" if not current.isValid(): @@ -239,9 +224,13 @@ def _on_preset_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: preset = cast("ConfigPreset", node.payload) self._prop_table.setValue(preset.settings) + # ------------------------------------------------------------------ + # Property-table sync + # ------------------------------------------------------------------ + def _on_prop_table_changed(self) -> None: """Write back edits from the table into the underlying ConfigPreset.""" - idx = self._current_preset_index() + idx = self._preset_view.currentIndex() if not idx.isValid(): return node = cast("_Node", idx.internalPointer()) @@ -258,7 +247,7 @@ def _on_model_data_changed( _roles: list[int] | None = None, ) -> None: """Refresh DevicePropertyTable if a setting in the current preset was edited.""" - cur_preset = self._current_preset_index() + cur_preset = self._preset_view.currentIndex() if not cur_preset.isValid(): return From 3692bb4f0a690d103a65ed9fe59b73b4d3a1e275 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 25 Jun 2025 19:56:03 -0400 Subject: [PATCH 13/70] breaking into two --- .../config_presets/_qmodel/_config_views.py | 115 +++++++++++++++--- .../_device_property_table.py | 8 +- 2 files changed, 106 insertions(+), 17 deletions(-) diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py index 9ae73b2bf..930c996a9 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Callable, cast +from pymmcore_plus import DeviceProperty, DeviceType, Keyword from qtpy.QtCore import QAbstractItemModel, QModelIndex, QSize, Qt, Signal from qtpy.QtWidgets import ( QGroupBox, @@ -16,15 +17,107 @@ ) from superqt import QIconifyIcon -from pymmcore_widgets.device_properties import DevicePropertyTable -from pymmcore_widgets.device_properties._property_widget import PropertyWidget +from pymmcore_widgets.device_properties import DevicePropertyTable, PropertyWidget from ._config_model import QConfigTreeModel, _Node if TYPE_CHECKING: - from collections.abc import Iterable, Sequence + from collections.abc import Callable, Iterable, Sequence - from pymmcore_plus.model import ConfigGroup, ConfigPreset + from pymmcore_plus.model import ConfigGroup, ConfigPreset, Setting + + +def _is_not_objective(prop: DeviceProperty) -> bool: + return not any(x in prop.device for x in prop.core.guessObjectiveDevices()) + + +def _light_path_predicate(prop: DeviceProperty) -> bool | None: + devtype = prop.deviceType() + if devtype in ( + DeviceType.Camera, + DeviceType.Core, + DeviceType.AutoFocus, + DeviceType.Stage, + DeviceType.XYStage, + ): + return False + if devtype == DeviceType.State: + if "State" in prop.name or "ClosedPosition" in prop.name: + return False + if devtype == DeviceType.Shutter and prop.name == Keyword.State.value: + return False + if not _is_not_objective(prop): + return False + return None + + +class DualDevicePropertyTable(QWidget): + valueChanged = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + + # widgets -------------------------------------------------------------- + + self._light_path_table = DevicePropertyTable() + self._light_path_table.setRowsCheckable(True) + self._light_path_table.valueChanged.connect(self.valueChanged) + + self._camera_table = DevicePropertyTable() + self._camera_table.setRowsCheckable(True) + self._camera_table.valueChanged.connect(self.valueChanged) + + # layout ------------------------------------------------------------ + + light_path_group = QGroupBox("Light Path", self) + layout = QVBoxLayout(light_path_group) + layout.addWidget(self._light_path_table) + + camera_group = QGroupBox("Camera", self) + layout = QVBoxLayout(camera_group) + layout.addWidget(self._camera_table) + + splitter = QSplitter(Qt.Orientation.Vertical, self) + splitter.addWidget(light_path_group) + splitter.addWidget(camera_group) + splitter.setStretchFactor(0, 3) + splitter.setStretchFactor(1, 1) + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(splitter) + + # init ------------------------------------------------------------ + + self.filterDevices() + + # --------------------------------------------------------------------- API + def value(self) -> list[Setting]: + """Return the union of checked settings from both panels.""" + # remove duplicates by converting to a dict keyed on (device, prop_name) + settings = { + (setting[0], setting[1]): setting + for table in (self._light_path_table, self._camera_table) + for setting in table.getCheckedProperties(visible_only=True) + } + return list(settings.values()) + + def setValue(self, value: Iterable[Setting]) -> None: + self._light_path_table.setValue(value) + self._camera_table.setValue(value) + + def filterDevices(self) -> None: + """Call ``filterDevices`` on *both* tables with the same arguments.""" + self._light_path_table.filterDevices( + include_pre_init=False, + include_read_only=False, + predicate=_light_path_predicate, + ) + self._camera_table.filterDevices( + include_devices=[DeviceType.Camera], + include_pre_init=False, + include_read_only=False, + ) class SettingValueDelegate(QStyledItemDelegate): @@ -101,7 +194,6 @@ def __init__( self._view = list_view self._model = cast("QConfigTreeModel", list_view.model()) layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self._toolbar) layout.addWidget(list_view) @@ -140,10 +232,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._preset_view.setModel(self._model) preset_box = _NameList("Presets", self, self._new_preset, self._preset_view) - self._prop_table = DevicePropertyTable() - self._prop_table.setRowsCheckable(True) - self._prop_table.filterDevices(include_pre_init=False, include_read_only=False) - self._prop_table.valueChanged.connect(self._on_prop_table_changed) + self._prop_table = DualDevicePropertyTable() # layout ------------------------------------------------------------ @@ -153,13 +242,10 @@ def __init__(self, parent: QWidget | None = None) -> None: lv.addWidget(group_box) lv.addWidget(preset_box) - splitter = QSplitter() - splitter.addWidget(left) - splitter.addWidget(self._prop_table) - lay = QHBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) - lay.addWidget(splitter) + lay.addWidget(left) + lay.addWidget(self._prop_table) # signals ------------------------------------------------------------ @@ -168,6 +254,7 @@ def __init__(self, parent: QWidget | None = None) -> None: if sm := self._preset_view.selectionModel(): sm.currentChanged.connect(self._on_preset_sel) self._model.dataChanged.connect(self._on_model_data_changed) + self._prop_table.valueChanged.connect(self._on_prop_table_changed) # ------------------------------------------------------------------ # Public API diff --git a/src/pymmcore_widgets/device_properties/_device_property_table.py b/src/pymmcore_widgets/device_properties/_device_property_table.py index e379a8269..9862efca7 100644 --- a/src/pymmcore_widgets/device_properties/_device_property_table.py +++ b/src/pymmcore_widgets/device_properties/_device_property_table.py @@ -280,7 +280,7 @@ def filterDevices( self.showRow(row) - def getCheckedProperties(self) -> list[Setting]: + def getCheckedProperties(self, *, visible_only: bool = False) -> list[Setting]: """Return a list of checked properties. Each item in the list is a tuple of (device, property, value). @@ -290,8 +290,10 @@ def getCheckedProperties(self) -> list[Setting]: dev_prop_val_list: list[Setting] = [] for row in range(self.rowCount()): if ( - item := self.item(row, 0) - ) and item.checkState() == Qt.CheckState.Checked: + (item := self.item(row, 0)) + and item.checkState() == Qt.CheckState.Checked + and (not visible_only or not self.isRowHidden(row)) + ): dev_prop_val_list.append(Setting(*self.getRowData(row))) return dev_prop_val_list From bced4e3e649e668cb579e1ee066068275fbaa372 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 26 Jun 2025 16:06:21 -0400 Subject: [PATCH 14/70] feat: restructure configuration editor and update device property handling --- example.py | 53 --- examples/config_groups_editor.py | 30 ++ src/pymmcore_widgets/_icons.py | 2 +- .../config_presets/_qmodel/_config_model.py | 4 +- .../_qmodel/_config_properties.py | 374 ++++++++++++++++++ .../config_presets/_qmodel/_config_views.py | 166 +++----- .../_device_property_table.py | 8 +- 7 files changed, 461 insertions(+), 176 deletions(-) delete mode 100644 example.py create mode 100644 examples/config_groups_editor.py create mode 100644 src/pymmcore_widgets/config_presets/_qmodel/_config_properties.py diff --git a/example.py b/example.py deleted file mode 100644 index 5237152ea..000000000 --- a/example.py +++ /dev/null @@ -1,53 +0,0 @@ -from pymmcore_plus import CMMCorePlus -from pymmcore_plus.model import ConfigGroup, ConfigPreset, Setting -from qtpy.QtWidgets import QApplication, QHBoxLayout, QTreeView, QWidget - -from pymmcore_widgets import ConfigGroupsEditor -from pymmcore_widgets.config_presets._qmodel._config_views import SettingValueDelegate - -app = QApplication([]) -core = CMMCorePlus() -core.loadSystemConfiguration() - -# sample config ---------------------------------------------------------- -cam_grp = ConfigGroup( - "Camera", - presets={ - "Cy5": ConfigPreset( - name="Cy5", - settings=[ - Setting("Dichroic", "Label", "400DCLP"), - Setting("Camera", "Gain", "0"), - Setting("Core", "Shutter", "White Light Shutter"), - ], - ), - "FITC": ConfigPreset( - name="FITC", - settings=[ - Setting("Dichroic", "Label", "400DCLP"), - Setting("Emission", "Label", "Chroma-HQ620"), - ], - ), - }, -) -obj_grp = ConfigGroup("Objective") - -cfg = ConfigGroupsEditor() -cfg.setData([cam_grp, obj_grp]) - -# right-hand tree view showing the *same* model -tree = QTreeView() -tree.setModel(cfg._model) -tree.setColumnWidth(0, 160) # column 0 (Name) width -# column 2 (Value) uses a line-edit when editing a Setting -tree.setItemDelegateForColumn(2, SettingValueDelegate(tree)) - - -w = QWidget() -layout = QHBoxLayout(w) -layout.addWidget(cfg) -layout.addWidget(tree) -w.resize(1400, 800) -w.show() - -app.exec() diff --git a/examples/config_groups_editor.py b/examples/config_groups_editor.py new file mode 100644 index 000000000..b3b489b03 --- /dev/null +++ b/examples/config_groups_editor.py @@ -0,0 +1,30 @@ +from pymmcore_plus import CMMCorePlus +from qtpy.QtCore import QModelIndex +from qtpy.QtWidgets import QApplication, QHBoxLayout, QTreeView, QWidget + +from pymmcore_widgets import ConfigGroupsEditor +from pymmcore_widgets.config_presets._qmodel._config_views import PropertyValueDelegate + +app = QApplication([]) +core = CMMCorePlus() +core.loadSystemConfiguration() + +cfg = ConfigGroupsEditor.create_from_core(core) + +# right-hand tree view showing the *same* model +tree = QTreeView() +tree.setModel(cfg._model) +tree.expandRecursively(QModelIndex()) +tree.setColumnWidth(0, 180) +# make values in in the tree editable +tree.setItemDelegateForColumn(2, PropertyValueDelegate(tree)) + + +w = QWidget() +layout = QHBoxLayout(w) +layout.addWidget(cfg) +layout.addWidget(tree) +w.resize(1400, 800) +w.show() + +app.exec() diff --git a/src/pymmcore_widgets/_icons.py b/src/pymmcore_widgets/_icons.py index eb9ae537b..4c4ebf8ae 100644 --- a/src/pymmcore_widgets/_icons.py +++ b/src/pymmcore_widgets/_icons.py @@ -6,7 +6,7 @@ DeviceType.Any: "mdi:devices", DeviceType.AutoFocus: "mdi:auto-upload", DeviceType.Camera: "mdi:camera", - DeviceType.Core: "mdi:checkbox-blank-circle-outline", + DeviceType.Core: "mdi:heart-cog-outline", DeviceType.Galvo: "mdi:mirror-variant", DeviceType.Generic: "mdi:dev-to", DeviceType.Hub: "mdi:hubspot", diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py index 4acabaaa5..138b65baf 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py @@ -66,7 +66,7 @@ def is_setting(self) -> bool: return isinstance(self.payload, Setting) -class QConfigTreeModel(QAbstractItemModel): +class QConfigGroupsModel(QAbstractItemModel): """Three-level model: root → groups → presets → settings.""" def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: @@ -407,7 +407,7 @@ class SettingValueDelegate(QStyledItemDelegate): """Item delegate that uses a PropertyWidget for editing PropertySetting values.""" def createEditor( - self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex + self, parent: QWidget | None, option: QStyleOptionViewItem, index: QModelIndex ) -> QWidget | None: node = cast("_Node", index.internalPointer()) if ( diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_properties.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_properties.py new file mode 100644 index 000000000..f090386e3 --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_properties.py @@ -0,0 +1,374 @@ +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, ClassVar, cast + +from pymmcore_plus import CMMCorePlus, DeviceProperty, DeviceType, Keyword +from pymmcore_plus.model import Setting +from qtpy.QtCore import QEvent, Qt, Signal +from qtpy.QtWidgets import ( + QCheckBox, + QComboBox, + QGroupBox, + QHBoxLayout, + QLabel, + QSpacerItem, + QSplitter, + QVBoxLayout, + QWidget, +) + +from pymmcore_widgets.device_properties import DevicePropertyTable + +if TYPE_CHECKING: + from qtpy.QtGui import QMouseEvent + + +class GroupedDevicePropertyTable(QWidget): + """More opinionated arrangement of device properties. + + This widget contains multiple `DevicePropertyTable` widgets, each filtered + to highlight different aspects of the device properties. + + It's API mimics that of `DevicePropertyTable`, allowing you to get and set + the union of checked settings across all sub-tables... it should be a drop-in + replacement for `DevicePropertyTable` in most cases. (assuming you limit your API + to value(), setValue(), and valueChanged()). + """ + + valueChanged = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + + # Groups ----------------------------------------------------- + self.light_path_props = _LightPathGroupBox(self) + self.camera_props = _CameraGroupBox(self) + + # layout ------------------------------------------------------------ + + splitter = QSplitter(Qt.Orientation.Vertical, self) + splitter.setContentsMargins(0, 0, 0, 0) + splitter.addWidget(self.light_path_props) + splitter.addWidget(self.camera_props) + splitter.setStretchFactor(0, 3) + splitter.setStretchFactor(1, 1) + splitter.setStretchFactor(2, 0) + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(splitter) + + # init ------------------------------------------------------------ + + self.light_path_props.valueChanged.connect(self.valueChanged) + self.camera_props.valueChanged.connect(self.valueChanged) + + # --------------------------------------------------------------------- API + + def value(self) -> list[Setting]: + """Return the union of checked settings from both panels.""" + # remove duplicates by converting to a dict keyed on (device, prop_name) + settings = { + (setting[0], setting[1]): setting + for group in (self.light_path_props, self.camera_props) + for setting in group.value() + } + return list(settings.values()) + + def setValue(self, value: Iterable[Setting]) -> None: + self.light_path_props.setValue(value) + self.camera_props.setValue(value) + + def update_options_from_core(self, core: CMMCorePlus) -> None: + """Populate the comboboxes with the available devices from the core.""" + self.light_path_props.active_shutter.update_from_core(core) + self.camera_props.active_camera.update_from_core(core) + + +def _is_not_objective(prop: DeviceProperty) -> bool: + return not any(x in prop.device for x in prop.core.guessObjectiveDevices()) + + +def _light_path_predicate(prop: DeviceProperty) -> bool | None: + devtype = prop.deviceType() + if devtype in ( + DeviceType.Camera, + DeviceType.Core, + DeviceType.AutoFocus, + DeviceType.Stage, + DeviceType.XYStage, + ): + return False + if devtype == DeviceType.State: + if "State" in prop.name or "ClosedPosition" in prop.name: + return False + if devtype == DeviceType.Shutter and prop.name == Keyword.State.value: + return False + if not _is_not_objective(prop): + return False + return None + + +class _LightPathGroupBox(QGroupBox): + valueChanged = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__("Light Path", parent) + # self.setCheckable(True) + self.toggled.connect(self.valueChanged) + + self.active_shutter = CoreRoleSelector(DeviceType.ShutterDevice, self) + self.active_shutter.valueChanged.connect(self.valueChanged) + + self.show_all = QCheckBox("Show All Properties", self) + self.show_all.toggled.connect(self._show_all_toggled) + + self.props = DevicePropertyTable(self, connect_core=False) + self.props.valueChanged.connect(self.valueChanged) + self.props.setRowsCheckable(True) + self.props.filterDevices( + include_read_only=False, + include_pre_init=False, + predicate=_light_path_predicate, + ) + + shutter_layout = QHBoxLayout() + shutter_layout.setContentsMargins(2, 0, 0, 0) + shutter_layout.addWidget(self.active_shutter, 1) + shutter_layout.addSpacerItem(QSpacerItem(40, 0)) + shutter_layout.addWidget(self.show_all, 0) + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(0) + layout.addLayout(shutter_layout) + layout.addWidget(self.props) + + def _show_all_toggled(self, show_all: bool) -> None: + self.props.filterDevices( + exclude_devices=(DeviceType.Camera, DeviceType.Core), + include_read_only=False, + include_pre_init=False, + always_show_checked=True, + predicate=_light_path_predicate if not show_all else _is_not_objective, + ) + + def value(self) -> Iterable[Setting]: + yield from self.props.getCheckedProperties(visible_only=True) + if self.active_shutter.isChecked(): + yield Setting( + Keyword.CoreDevice.value, + Keyword.CoreShutter.value, + self.active_shutter.currentText(), + ) + + def setValue(self, value: Iterable[tuple[str, str, str]]) -> None: + """Set the value of the properties in this group.""" + self.props.setValue(value) + self.active_shutter.setChecked(False) + for device, prop, val in value: + if device == Keyword.CoreDevice.value and prop == Keyword.CoreShutter.value: + self.active_shutter.setCurrentText(val) + self.active_shutter.setChecked(True) + break + + +class _CameraGroupBox(QGroupBox): + valueChanged = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__("Camera", parent) + self.setCheckable(True) + self.setChecked(True) + self.toggled.connect(self.valueChanged) + + self.props = DevicePropertyTable(self, connect_core=False) + self.props.valueChanged.connect(self.valueChanged) + self.props.setRowsCheckable(True) + self.props.filterDevices( + include_devices=[DeviceType.Camera], + include_read_only=False, + ) + + self.active_camera = CoreRoleSelector(DeviceType.CameraDevice, self) + self.active_camera.valueChanged.connect(self.valueChanged) + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(0) + layout.addWidget(self.active_camera) + layout.addWidget(self.props) + + def value(self) -> Iterable[Setting]: + if not self.isChecked(): + return + yield from self.props.getCheckedProperties(visible_only=True) + if self.active_camera.isChecked(): + yield Setting( + Keyword.CoreDevice.value, + Keyword.CoreCamera.value, + self.active_camera.currentText(), + ) + + def setValue(self, value: Iterable[tuple[str, str, str]]) -> None: + """Set the value of the properties in this group.""" + self.props.setValue(value) + + self.active_camera.setChecked(False) + for device, prop, val in value: + if device == Keyword.CoreDevice.value and prop == Keyword.CoreCamera.value: + self.active_camera.setCurrentText(val) + self.active_camera.setChecked(True) + break + + if ( + self.props.getCheckedProperties(visible_only=True) + or self.active_camera.isChecked() + ): + self.setChecked(True) + + +class _ClickableLabel(QLabel): + """A QLabel that emits a signal when clicked (even when disabled).""" + + clicked = Signal() + + def event(self, event: QEvent | None) -> bool: + """Override event to handle mouse press even when disabled.""" + if event and event.type() == (QEvent.Type.MouseButtonRelease): + if cast("QMouseEvent", event).button() == Qt.MouseButton.LeftButton: + self.clicked.emit() + return True + return super().event(event) # type: ignore[no-any-return] + + +class CheckableComboBox(QWidget): + """Row containing checkbox, label, and combobox. + + Useful for settings that can be enabled/disabled with a checkbox. + (Rather than adding a "null" option to the combobox.) + """ + + valueChanged = Signal() + + def __init__( + self, + label: str | None = None, + parent: QWidget | None = None, + *, + checkable: bool = True, + ) -> None: + super().__init__(parent) + + self.checkbox = QCheckBox(self) # not using label so we can independent enable + self.label = _ClickableLabel(label or "", self) + self.combobox = QComboBox(self) + + self.combobox.currentTextChanged.connect(self.valueChanged) + self.checkbox.toggled.connect(self.valueChanged) + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.checkbox) + layout.addWidget(self.label) + layout.addWidget(self.combobox, 1) + + if checkable: + self.label.clicked.connect(self.checkbox.toggle) + self.label.setEnabled(False) + self.combobox.setEnabled(False) + else: + self.checkbox.setEnabled(False) + self.checkbox.setVisible(False) + + self.checkbox.checkStateChanged.connect(self._on_checkbox_changed) + + def clear(self) -> None: + """Clear the combobox.""" + self.combobox.clear() + + def addItem(self, item: str) -> None: + """Add item to the combobox.""" + self.combobox.addItem(item) + + def addItems(self, items: Iterable[str]) -> None: + """Add items to the combobox.""" + self.combobox.addItems(items) + + def isChecked(self) -> bool: + """Return whether the checkbox is checked.""" + return self.checkbox.isChecked() # type: ignore[no-any-return] + + def setChecked(self, checked: bool) -> None: + """Set the checkbox state.""" + self.checkbox.setChecked(checked) + + def currentText(self) -> str: + """Return the current text of the combobox.""" + return self.combobox.currentText() # type: ignore[no-any-return] + + def setCurrentText(self, text: str) -> None: + """Set the current text of the combobox.""" + self.combobox.setCurrentText(text) + + def _on_checkbox_changed(self, state: Qt.CheckState) -> None: + """Handle checkbox state change.""" + checked = state == Qt.CheckState.Checked + self.combobox.setEnabled(checked) + self.label.setEnabled(checked) + + +class CoreRoleSelector(CheckableComboBox): + """Widget for selecting a core role.""" + + METHOD_MAP: ClassVar[Mapping[DeviceType, str]] = { + DeviceType.Camera: "getCameraDevice", + DeviceType.Shutter: "getShutterDevice", + DeviceType.Stage: "getFocusDevice", + DeviceType.XYStage: "getXYStageDevice", + DeviceType.AutoFocus: "getAutoFocusDevice", + DeviceType.ImageProcessor: "getImageProcessorDevice", + DeviceType.SLM: "getSLMDevice", + DeviceType.Galvo: "getGalvoDevice", + } + + def __init__( + self, + device_type: DeviceType, + parent: QWidget | None = None, + *, + label: str | None = None, + ) -> None: + if device_type not in CoreRoleSelector.METHOD_MAP: + raise ValueError(f"MMCore has no 'current' {device_type.name} ") + + self.device_type = device_type + if label is None: + label = f"Active {device_type.name.replace('Device', '')}:" + super().__init__(label, parent, checkable=True) + + def update_from_core( + self, + core: CMMCorePlus | None = None, + *, + update_options: bool = True, + update_current: bool = True, + ) -> None: + """Update the combobox with the current core settings. + + If `update_options` is True, it will refresh the list of devices. + If `update_current` is True, it will set the current text to the active device. + """ + core = core or CMMCorePlus.instance() + + if update_options: + self.clear() + devices = core.getLoadedDevicesOfType(self.device_type) + self.addItems(["", *devices]) + + if update_current: + method_name = self.METHOD_MAP[self.device_type] + method = getattr(core, method_name) + try: + self.setCurrentText(method()) + except Exception: + self.setCurrentText("") diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py index 930c996a9..17fe44b86 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py @@ -2,13 +2,13 @@ from typing import TYPE_CHECKING, Callable, cast -from pymmcore_plus import DeviceProperty, DeviceType, Keyword +from pymmcore_plus.model import ConfigGroup from qtpy.QtCore import QAbstractItemModel, QModelIndex, QSize, Qt, Signal from qtpy.QtWidgets import ( QGroupBox, QHBoxLayout, + QLabel, QListView, - QSplitter, QStyledItemDelegate, QStyleOptionViewItem, QToolBar, @@ -17,110 +17,19 @@ ) from superqt import QIconifyIcon -from pymmcore_widgets.device_properties import DevicePropertyTable, PropertyWidget +from pymmcore_widgets.device_properties import PropertyWidget -from ._config_model import QConfigTreeModel, _Node +from ._config_model import QConfigGroupsModel, _Node +from ._config_properties import GroupedDevicePropertyTable if TYPE_CHECKING: from collections.abc import Callable, Iterable, Sequence - from pymmcore_plus.model import ConfigGroup, ConfigPreset, Setting + from pymmcore_plus import CMMCorePlus + from pymmcore_plus.model import ConfigPreset -def _is_not_objective(prop: DeviceProperty) -> bool: - return not any(x in prop.device for x in prop.core.guessObjectiveDevices()) - - -def _light_path_predicate(prop: DeviceProperty) -> bool | None: - devtype = prop.deviceType() - if devtype in ( - DeviceType.Camera, - DeviceType.Core, - DeviceType.AutoFocus, - DeviceType.Stage, - DeviceType.XYStage, - ): - return False - if devtype == DeviceType.State: - if "State" in prop.name or "ClosedPosition" in prop.name: - return False - if devtype == DeviceType.Shutter and prop.name == Keyword.State.value: - return False - if not _is_not_objective(prop): - return False - return None - - -class DualDevicePropertyTable(QWidget): - valueChanged = Signal() - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - - # widgets -------------------------------------------------------------- - - self._light_path_table = DevicePropertyTable() - self._light_path_table.setRowsCheckable(True) - self._light_path_table.valueChanged.connect(self.valueChanged) - - self._camera_table = DevicePropertyTable() - self._camera_table.setRowsCheckable(True) - self._camera_table.valueChanged.connect(self.valueChanged) - - # layout ------------------------------------------------------------ - - light_path_group = QGroupBox("Light Path", self) - layout = QVBoxLayout(light_path_group) - layout.addWidget(self._light_path_table) - - camera_group = QGroupBox("Camera", self) - layout = QVBoxLayout(camera_group) - layout.addWidget(self._camera_table) - - splitter = QSplitter(Qt.Orientation.Vertical, self) - splitter.addWidget(light_path_group) - splitter.addWidget(camera_group) - splitter.setStretchFactor(0, 3) - splitter.setStretchFactor(1, 1) - - lay = QVBoxLayout(self) - lay.setContentsMargins(0, 0, 0, 0) - lay.addWidget(splitter) - - # init ------------------------------------------------------------ - - self.filterDevices() - - # --------------------------------------------------------------------- API - def value(self) -> list[Setting]: - """Return the union of checked settings from both panels.""" - # remove duplicates by converting to a dict keyed on (device, prop_name) - settings = { - (setting[0], setting[1]): setting - for table in (self._light_path_table, self._camera_table) - for setting in table.getCheckedProperties(visible_only=True) - } - return list(settings.values()) - - def setValue(self, value: Iterable[Setting]) -> None: - self._light_path_table.setValue(value) - self._camera_table.setValue(value) - - def filterDevices(self) -> None: - """Call ``filterDevices`` on *both* tables with the same arguments.""" - self._light_path_table.filterDevices( - include_pre_init=False, - include_read_only=False, - predicate=_light_path_predicate, - ) - self._camera_table.filterDevices( - include_devices=[DeviceType.Camera], - include_pre_init=False, - include_read_only=False, - ) - - -class SettingValueDelegate(QStyledItemDelegate): +class PropertyValueDelegate(QStyledItemDelegate): """Item delegate that uses a PropertyWidget for editing PropertySetting values.""" def createEditor( @@ -162,18 +71,19 @@ def setModelData( # ----------------------------------------------------------------------------- -class _NameList(QGroupBox): +class _NameList(QWidget): """A group box that contains a toolbar and a QListView for cfg groups or presets.""" def __init__( self, title: str, parent: QWidget | None, new_fn: Callable, list_view: QListView ) -> None: - super().__init__(title, parent) + super().__init__(parent) + self._title = title # toolbar self._toolbar = QToolBar() self._toolbar.setIconSize(QSize(18, 18)) - self._toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + # self._toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) self._toolbar.addAction( QIconifyIcon("mdi:plus-thick", color="gray"), @@ -192,14 +102,21 @@ def __init__( ) self._view = list_view - self._model = cast("QConfigTreeModel", list_view.model()) + self._model = cast("QConfigGroupsModel", list_view.model()) layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) layout.addWidget(self._toolbar) layout.addWidget(list_view) + if isinstance(self, QGroupBox): + self.setTitle(title) + else: + layout.insertWidget(0, QLabel(self._title, self)) + def _is_groups(self) -> bool: """Check if this box is for groups.""" - return bool(self.title() == "Groups") + return bool(self._title == "Groups") def _remove(self) -> None: self._model.remove(self._view.currentIndex()) @@ -213,14 +130,30 @@ def _dupe(self) -> None: self._view.setCurrentIndex(self._model.duplicate_preset(idx)) +# This should perhaps be a QAbstractItemView class ConfigGroupsEditor(QWidget): """Widget composed of two QListViews backed by a single tree model.""" configChanged = Signal() + @classmethod + def create_from_core( + cls, core: CMMCorePlus, parent: QWidget | None = None + ) -> ConfigGroupsEditor: + """Create a ConfigGroupsEditor from a CMMCorePlus instance.""" + obj = cls(parent) + groups = ConfigGroup.all_config_groups(core) + obj.setData(groups.values()) + obj.update_options_from_core(core) + return obj + + def update_options_from_core(self, core: CMMCorePlus) -> None: + """Populate the comboboxes with the available devices from the core.""" + self._prop_tables.update_options_from_core(core) + def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) - self._model = QConfigTreeModel() + self._model = QConfigGroupsModel() # widgets -------------------------------------------------------------- @@ -232,7 +165,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._preset_view.setModel(self._model) preset_box = _NameList("Presets", self, self._new_preset, self._preset_view) - self._prop_table = DualDevicePropertyTable() + self._prop_tables = GroupedDevicePropertyTable() # layout ------------------------------------------------------------ @@ -245,7 +178,7 @@ def __init__(self, parent: QWidget | None = None) -> None: lay = QHBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.addWidget(left) - lay.addWidget(self._prop_table) + lay.addWidget(self._prop_tables) # signals ------------------------------------------------------------ @@ -254,7 +187,7 @@ def __init__(self, parent: QWidget | None = None) -> None: if sm := self._preset_view.selectionModel(): sm.currentChanged.connect(self._on_preset_sel) self._model.dataChanged.connect(self._on_model_data_changed) - self._prop_table.valueChanged.connect(self._on_prop_table_changed) + self._prop_tables.valueChanged.connect(self._on_prop_table_changed) # ------------------------------------------------------------------ # Public API @@ -262,8 +195,9 @@ def __init__(self, parent: QWidget | None = None) -> None: def setData(self, data: Iterable[ConfigGroup]) -> None: """Set the configuration data to be displayed in the editor.""" + data = list(data) # ensure we can iterate multiple times self._model.set_groups(data) - self._prop_table.setValue([]) + self._prop_tables.setValue([]) # Auto-select first group if self._model.rowCount(): self._group_view.setCurrentIndex(self._model.index(0)) @@ -302,14 +236,14 @@ def _on_preset_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: """Populate the DevicePropertyTable whenever the selected preset changes.""" if not current.isValid(): # clear table when nothing is selected - self._prop_table.setValue([]) + self._prop_tables.setValue([]) return node = cast("_Node", current.internalPointer()) if not node.is_preset: - self._prop_table.setValue([]) + self._prop_tables.setValue([]) return preset = cast("ConfigPreset", node.payload) - self._prop_table.setValue(preset.settings) + self._prop_tables.setValue(preset.settings) # ------------------------------------------------------------------ # Property-table sync @@ -323,7 +257,7 @@ def _on_prop_table_changed(self) -> None: node = cast("_Node", idx.internalPointer()) if not node.is_preset: return - new_settings = self._prop_table.value() + new_settings = self._prop_tables.value() self._model.update_preset_settings(idx, new_settings) self.configChanged.emit() @@ -346,6 +280,6 @@ def _on_model_data_changed( # pull updated settings from the model and push to the table node = cast("_Node", cur_preset.internalPointer()) preset = cast("ConfigPreset", node.payload) - self._prop_table.blockSignals(True) # avoid feedback loop - self._prop_table.setValue(preset.settings) - self._prop_table.blockSignals(False) + self._prop_tables.blockSignals(True) # avoid feedback loop + self._prop_tables.setValue(preset.settings) + self._prop_tables.blockSignals(False) diff --git a/src/pymmcore_widgets/device_properties/_device_property_table.py b/src/pymmcore_widgets/device_properties/_device_property_table.py index 9862efca7..2030c03b8 100644 --- a/src/pymmcore_widgets/device_properties/_device_property_table.py +++ b/src/pymmcore_widgets/device_properties/_device_property_table.py @@ -207,7 +207,7 @@ def filterDevices( include_devices: Iterable[DeviceType] = (), include_read_only: bool = True, include_pre_init: bool = True, - always_include_checked: bool = False, + always_show_checked: bool = False, predicate: Callable[[DeviceProperty], bool | None] | None = None, ) -> None: """Update the table to only show devices that match the given query/filter. @@ -217,7 +217,7 @@ def filterDevices( will be considered. 2. If `exclude_devices` is provided, devices of the specified types will be hidden (even if they are in `include_devices`). - 3. If `always_include_checked` is True, remaining rows that are checked will + 3. If `always_show_checked` is True, remaining rows that are checked will always be shown, regardless of other filters. 4. If `predicate` is provided and it returns False, the row is hidden. 5. If `include_read_only` is False, read-only properties are hidden. @@ -237,7 +237,7 @@ def filterDevices( Whether to include read-only properties in the table, by default True include_pre_init : bool, optional Whether to include pre-initialized properties in the table, by default True - always_include_checked : bool, optional + always_show_checked : bool, optional Whether to always include rows that are checked, by default False. predicate : Callable[[DeviceProperty, QTableWidgetItem], bool | None] | None A function that takes a `DeviceProperty` and `QTableWidgetItem` and returns @@ -258,7 +258,7 @@ def filterDevices( self.hideRow(row) continue - if always_include_checked and item.checkState() == Qt.CheckState.Checked: + if always_show_checked and item.checkState() == Qt.CheckState.Checked: self.showRow(row) continue From 24eef5178948eceb9d8a706f6d85b9981cd6e494 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 26 Jun 2025 16:17:30 -0400 Subject: [PATCH 15/70] future import --- .../config_presets/_qmodel/_config_properties.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_properties.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_properties.py index f090386e3..1ad23e0e9 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_properties.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_properties.py @@ -1,4 +1,5 @@ -from collections.abc import Iterable, Mapping +from __future__ import annotations + from typing import TYPE_CHECKING, ClassVar, cast from pymmcore_plus import CMMCorePlus, DeviceProperty, DeviceType, Keyword @@ -19,6 +20,8 @@ from pymmcore_widgets.device_properties import DevicePropertyTable if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + from qtpy.QtGui import QMouseEvent From 1ed09ec1530579bff2e449147808882f64cf159e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 26 Jun 2025 16:21:19 -0400 Subject: [PATCH 16/70] docs --- src/pymmcore_widgets/config_presets/_qmodel/_config_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py index 138b65baf..86fb02ff1 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py @@ -33,7 +33,7 @@ class Col(IntEnum): class _Node: - """Generic tree node that wraps a ConfigGroup or ConfigPreset.""" + """Generic tree node that wraps a ConfigGroup, ConfigPreset, or Setting.""" def __init__( self, From 0423d0564b38c6cf3da6e53b9f54ae27672c8970 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 27 Jun 2025 13:46:18 -0400 Subject: [PATCH 17/70] feat: add methods to retrieve QModelIndex for groups and presets in QConfigGroupsModel fix: update icon names for AutoFocus and Magnifier device types refactor: enhance PropertyValueDelegate and ConfigGroupsEditor for better structure and functionality --- examples/config_groups_editor.py | 1 + src/pymmcore_widgets/_icons.py | 6 +- .../config_presets/_qmodel/_config_model.py | 19 ++ .../config_presets/_qmodel/_config_views.py | 291 ++++++++++++++---- 4 files changed, 261 insertions(+), 56 deletions(-) diff --git a/examples/config_groups_editor.py b/examples/config_groups_editor.py index b3b489b03..79f1c0d99 100644 --- a/examples/config_groups_editor.py +++ b/examples/config_groups_editor.py @@ -10,6 +10,7 @@ core.loadSystemConfiguration() cfg = ConfigGroupsEditor.create_from_core(core) +cfg.setCurrentPreset("Channel", "FITC") # right-hand tree view showing the *same* model tree = QTreeView() diff --git a/src/pymmcore_widgets/_icons.py b/src/pymmcore_widgets/_icons.py index 4c4ebf8ae..6c79daa27 100644 --- a/src/pymmcore_widgets/_icons.py +++ b/src/pymmcore_widgets/_icons.py @@ -4,16 +4,16 @@ ICONS: dict[DeviceType, str] = { DeviceType.Any: "mdi:devices", - DeviceType.AutoFocus: "mdi:auto-upload", + 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-plus", + DeviceType.Magnifier: "mdi:magnify", DeviceType.Shutter: "mdi:camera-iris", - DeviceType.SignalIO: "mdi:signal", + DeviceType.SignalIO: "fa6-solid:wave-square", DeviceType.SLM: "mdi:view-comfy", DeviceType.Stage: "mdi:arrow-up-down", DeviceType.State: "mdi:state-machine", diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py index 86fb02ff1..079aafdb2 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py @@ -79,6 +79,25 @@ def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: # Public helpers used by the widget toolbar actions # ------------------------------------------------------------------ + def index_for_group(self, group_name: str) -> QModelIndex: + """Return the QModelIndex for the group with the given name.""" + for i, node in enumerate(self._root.children): + if node.is_group and node.name == group_name: + return self.createIndex(i, 0, node) + return QModelIndex() + + def index_for_preset( + self, preset_name: str, group_index: QModelIndex + ) -> QModelIndex: + """Return the QModelIndex for the preset with the given name in the group.""" + if not self._is_group_index(group_index): + return QModelIndex() + parent_node = cast("_Node", group_index.internalPointer()) + for i, node in enumerate(parent_node.children): + if node.is_preset and node.name == preset_name: + return self.createIndex(i, 0, node) + return QModelIndex() + # group-level ------------------------------------------------------------- def add_group(self, base_name: str = "Group") -> QModelIndex: diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py index 17fe44b86..5f51137ce 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py @@ -1,7 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, cast +from typing import TYPE_CHECKING, cast +from pymmcore_plus import DeviceType from pymmcore_plus.model import ConfigGroup from qtpy.QtCore import QAbstractItemModel, QModelIndex, QSize, Qt, Signal from qtpy.QtWidgets import ( @@ -17,16 +18,19 @@ ) from superqt import QIconifyIcon -from pymmcore_widgets.device_properties import PropertyWidget +from pymmcore_widgets._icons import ICONS +from pymmcore_widgets.device_properties import DevicePropertyTable, PropertyWidget from ._config_model import QConfigGroupsModel, _Node -from ._config_properties import GroupedDevicePropertyTable if TYPE_CHECKING: - from collections.abc import Callable, Iterable, Sequence + from collections.abc import Iterable, Sequence from pymmcore_plus import CMMCorePlus - from pymmcore_plus.model import ConfigPreset + from pymmcore_plus.model import ConfigPreset, Setting + from PyQt6.QtGui import QAction, QActionGroup +else: + from qtpy.QtGui import QAction, QActionGroup class PropertyValueDelegate(QStyledItemDelegate): @@ -74,63 +78,91 @@ def setModelData( class _NameList(QWidget): """A group box that contains a toolbar and a QListView for cfg groups or presets.""" - def __init__( - self, title: str, parent: QWidget | None, new_fn: Callable, list_view: QListView - ) -> None: + def __init__(self, title: str, parent: QWidget | None) -> None: super().__init__(parent) self._title = title # toolbar - self._toolbar = QToolBar() - self._toolbar.setIconSize(QSize(18, 18)) - # self._toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + self.list_view = QListView(self) - self._toolbar.addAction( + self._toolbar = tb = QToolBar() + tb.setStyleSheet("QToolBar {background: none; border: none;}") + tb.setIconSize(QSize(18, 18)) + self._toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + + self.add_action = QAction( QIconifyIcon("mdi:plus-thick", color="gray"), f"Add {title.rstrip('s')}", - new_fn, + self, ) - self._toolbar.addAction( - QIconifyIcon("mdi:remove-bold", color="gray"), - "Remove", - self._remove, + tb.addAction(self.add_action) + tb.addSeparator() + tb.addAction( + QIconifyIcon("mdi:remove-bold", color="gray"), "Remove", self._remove ) - self._toolbar.addAction( - QIconifyIcon("mdi:content-duplicate", color="gray"), - "Duplicate", - self._dupe, + tb.addAction( + QIconifyIcon("mdi:content-duplicate", color="gray"), "Duplicate", self._dupe ) - self._view = list_view - self._model = cast("QConfigGroupsModel", list_view.model()) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self._toolbar) - layout.addWidget(list_view) + layout.addWidget(self.list_view) if isinstance(self, QGroupBox): self.setTitle(title) else: - layout.insertWidget(0, QLabel(self._title, self)) + label = QLabel(self._title, self) + font = label.font() + font.setBold(True) + label.setFont(font) + layout.insertWidget(0, label) def _is_groups(self) -> bool: """Check if this box is for groups.""" return bool(self._title == "Groups") def _remove(self) -> None: - self._model.remove(self._view.currentIndex()) + self._model.remove(self.list_view.currentIndex()) + + @property + def _model(self) -> QConfigGroupsModel: + """Return the model used by this list view.""" + model = self.list_view.model() + if not isinstance(model, QConfigGroupsModel): + raise TypeError("Expected a QConfigGroupsModel instance.") + return model + + def _dupe(self) -> None: ... + + +class GroupsList(_NameList): + def __init__(self, parent: QWidget | None) -> None: + super().__init__("Groups", parent) def _dupe(self) -> None: - idx = self._view.currentIndex() + idx = self.list_view.currentIndex() if idx.isValid(): - if self._is_groups(): - self._view.setCurrentIndex(self._model.duplicate_group(idx)) - else: - self._view.setCurrentIndex(self._model.duplicate_preset(idx)) + self.list_view.setCurrentIndex(self._model.duplicate_group(idx)) + + +class PresetsList(_NameList): + def __init__(self, parent: QWidget | None) -> None: + super().__init__("Presets", parent) + + # TODO: we need `_NameList.setCore()` in order to be able to "activate" a preset + self._toolbar.addAction( + QIconifyIcon("clarity:play-solid", color="gray"), + "Activate", + ) + + def _dupe(self) -> None: + idx = self.list_view.currentIndex() + if idx.isValid(): + self.list_view.setCurrentIndex(self._model.duplicate_preset(idx)) -# This should perhaps be a QAbstractItemView class ConfigGroupsEditor(QWidget): """Widget composed of two QListViews backed by a single tree model.""" @@ -142,14 +174,36 @@ def create_from_core( ) -> ConfigGroupsEditor: """Create a ConfigGroupsEditor from a CMMCorePlus instance.""" obj = cls(parent) - groups = ConfigGroup.all_config_groups(core) - obj.setData(groups.values()) - obj.update_options_from_core(core) + obj.update_from_core(core) return obj - def update_options_from_core(self, core: CMMCorePlus) -> None: - """Populate the comboboxes with the available devices from the core.""" - self._prop_tables.update_options_from_core(core) + def update_from_core( + self, + core: CMMCorePlus, + *, + update_configs: bool = True, + update_available: bool = True, + ) -> None: + """Update the editor's data from the core. + + Parameters + ---------- + core : CMMCorePlus + The core instance to pull configuration data from. + update_configs : bool + If True, update the entire list and states of config groups (i.e. make the + editor reflect the current state of config groups in the core). + update_available : bool + If True, update the available options in the property tables (for things + like "current device" comboboxes and other things that select from + available devices). + """ + if update_configs: + groups = ConfigGroup.all_config_groups(core) + self.setData(groups.values()) + if update_available: + self._props._update_device_buttons(core) + # self._prop_tables.update_options_from_core(core) def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) @@ -157,15 +211,17 @@ def __init__(self, parent: QWidget | None = None) -> None: # widgets -------------------------------------------------------------- - self._group_view = QListView() + group_box = GroupsList(self) + self._group_view = group_box.list_view self._group_view.setModel(self._model) - group_box = _NameList("Groups", self, self._new_group, self._group_view) + group_box.add_action.triggered.connect(self._new_group) - self._preset_view = QListView() + preset_box = PresetsList(self) + self._preset_view = preset_box.list_view self._preset_view.setModel(self._model) - preset_box = _NameList("Presets", self, self._new_preset, self._preset_view) + preset_box.add_action.triggered.connect(self._new_preset) - self._prop_tables = GroupedDevicePropertyTable() + self._props = _PropSettings(self) # layout ------------------------------------------------------------ @@ -178,7 +234,7 @@ def __init__(self, parent: QWidget | None = None) -> None: lay = QHBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.addWidget(left) - lay.addWidget(self._prop_tables) + lay.addWidget(self._props) # signals ------------------------------------------------------------ @@ -187,17 +243,35 @@ def __init__(self, parent: QWidget | None = None) -> None: if sm := self._preset_view.selectionModel(): sm.currentChanged.connect(self._on_preset_sel) self._model.dataChanged.connect(self._on_model_data_changed) - self._prop_tables.valueChanged.connect(self._on_prop_table_changed) + self._props.valueChanged.connect(self._on_prop_table_changed) # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ + def setCurrentGroup(self, group: str) -> None: + """Set the currently selected group in the editor.""" + idx = self._model.index_for_group(group) + if idx.isValid(): + self._group_view.setCurrentIndex(idx) + else: + self._group_view.clearSelection() + + def setCurrentPreset(self, group: str, preset: str) -> None: + """Set the currently selected preset in the editor.""" + self.setCurrentGroup(group) + group_index = self._model.index_for_group(group) + idx = self._model.index_for_preset(preset, group_index) + if idx.isValid(): + self._preset_view.setCurrentIndex(idx) + else: + self._preset_view.clearSelection() + def setData(self, data: Iterable[ConfigGroup]) -> None: """Set the configuration data to be displayed in the editor.""" data = list(data) # ensure we can iterate multiple times self._model.set_groups(data) - self._prop_tables.setValue([]) + self._props.setValue([]) # Auto-select first group if self._model.rowCount(): self._group_view.setCurrentIndex(self._model.index(0)) @@ -210,6 +284,9 @@ def data(self) -> Sequence[ConfigGroup]: """Return the current configuration data as a list of ConfigGroup.""" return self._model.data_as_groups() + # TODO: + # def selectionModel(self) -> QItemSelectionModel: ... + # "new" actions ---------------------------------------------------------- def _new_group(self) -> None: @@ -236,14 +313,14 @@ def _on_preset_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: """Populate the DevicePropertyTable whenever the selected preset changes.""" if not current.isValid(): # clear table when nothing is selected - self._prop_tables.setValue([]) + self._props.setValue([]) return node = cast("_Node", current.internalPointer()) if not node.is_preset: - self._prop_tables.setValue([]) + self._props.setValue([]) return preset = cast("ConfigPreset", node.payload) - self._prop_tables.setValue(preset.settings) + self._props.setValue(preset.settings) # ------------------------------------------------------------------ # Property-table sync @@ -257,7 +334,7 @@ def _on_prop_table_changed(self) -> None: node = cast("_Node", idx.internalPointer()) if not node.is_preset: return - new_settings = self._prop_tables.value() + new_settings = self._props.value() self._model.update_preset_settings(idx, new_settings) self.configChanged.emit() @@ -280,6 +357,114 @@ def _on_model_data_changed( # pull updated settings from the model and push to the table node = cast("_Node", cur_preset.internalPointer()) preset = cast("ConfigPreset", node.payload) - self._prop_tables.blockSignals(True) # avoid feedback loop - self._prop_tables.setValue(preset.settings) - self._prop_tables.blockSignals(False) + self._props.blockSignals(True) # avoid feedback loop + self._props.setValue(preset.settings) + self._props.blockSignals(False) + + +class _PropSettings(QWidget): + """A wrapper for DevicePropertyTable for use in ConfigGroupsEditor.""" + + valueChanged = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._action_group = QActionGroup(self) + self._action_group.setExclusive(False) + self._prop_tables = DevicePropertyTable() + self._prop_tables.setRowsCheckable(True) + + tb, self._action_group = self._create_device_buttons() + rv = QVBoxLayout(self) + rv.setContentsMargins(0, 0, 0, 0) + rv.addWidget(tb) + rv.addWidget(self._prop_tables) + + self._filter_properties() + + def value(self) -> list[Setting]: + """Return the current value of the property table.""" + return self._prop_tables.value() + + def setValue(self, value: list[Setting]) -> None: + """Set the value of the property table.""" + self._prop_tables.setValue(value) + + def _create_device_buttons(self) -> tuple[QToolBar, QActionGroup]: + tb = QToolBar() + tb.setMovable(False) + tb.setFloatable(False) + tb.setIconSize(QSize(18, 18)) + tb.setStyleSheet("QToolBar {background: none; border: none;}") + tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + tb.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + + # clear action group + action_group = QActionGroup(self) + action_group.setExclusive(False) + + for dev_type, checked in { + DeviceType.CameraDevice: False, + DeviceType.ShutterDevice: True, + DeviceType.StateDevice: True, + DeviceType.StageDevice: False, + DeviceType.XYStageDevice: False, + DeviceType.SerialDevice: False, + DeviceType.GenericDevice: False, + DeviceType.AutoFocusDevice: False, + DeviceType.ImageProcessorDevice: False, + DeviceType.SignalIODevice: False, + DeviceType.MagnifierDevice: False, + DeviceType.SLMDevice: False, + DeviceType.HubDevice: False, + DeviceType.GalvoDevice: False, + DeviceType.CoreDevice: False, + }.items(): + icon = QIconifyIcon(ICONS[dev_type], color="gray") + if act := tb.addAction( + icon, + dev_type.name.replace("Device", ""), + self._filter_properties, + ): + act.setCheckable(True) + act.setChecked(checked) + act.setData(dev_type) + action_group.addAction(act) + + return tb, action_group + + def _filter_properties(self) -> None: + include_devices = { + action.data() + for action in self._action_group.actions() + if action.isChecked() + } + if not include_devices: + # If no devices are selected, show all properties + for row in range(self._prop_tables.rowCount()): + self._prop_tables.hideRow(row) + + else: + self._prop_tables.filterDevices( + include_pre_init=False, + include_read_only=False, + always_show_checked=True, + include_devices=include_devices, + ) + + def _update_device_buttons(self, core: CMMCorePlus) -> None: + for action in self._action_group.actions(): + dev_type = cast("DeviceType", action.data()) + for dev in core.getLoadedDevicesOfType(dev_type): + writeable_props = ( + ( + not core.isPropertyPreInit(dev, prop) + and not core.isPropertyReadOnly(dev, prop) + ) + for prop in core.getDevicePropertyNames(dev) + ) + if any(writeable_props): + action.setVisible(True) + break + else: + action.setVisible(False) From 8b21d733cf3609427575119b50168205f824198d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 27 Jun 2025 16:48:59 -0400 Subject: [PATCH 18/70] wip: preset-table --- examples/config_groups_editor.py | 4 +- examples/preset_table_example.py | 69 +++ .../config_presets/_qmodel/_config_views.py | 43 +- .../config_presets/_qmodel/_preset_table.py | 489 ++++++++++++++++++ .../_qmodel/_property_value_delegate.py | 46 ++ 5 files changed, 609 insertions(+), 42 deletions(-) create mode 100644 examples/preset_table_example.py create mode 100644 src/pymmcore_widgets/config_presets/_qmodel/_preset_table.py create mode 100644 src/pymmcore_widgets/config_presets/_qmodel/_property_value_delegate.py diff --git a/examples/config_groups_editor.py b/examples/config_groups_editor.py index 79f1c0d99..641166d1a 100644 --- a/examples/config_groups_editor.py +++ b/examples/config_groups_editor.py @@ -3,7 +3,9 @@ from qtpy.QtWidgets import QApplication, QHBoxLayout, QTreeView, QWidget from pymmcore_widgets import ConfigGroupsEditor -from pymmcore_widgets.config_presets._qmodel._config_views import PropertyValueDelegate +from pymmcore_widgets.config_presets._qmodel._property_value_delegate import ( + PropertyValueDelegate, +) app = QApplication([]) core = CMMCorePlus() diff --git a/examples/preset_table_example.py b/examples/preset_table_example.py new file mode 100644 index 000000000..711c13bf0 --- /dev/null +++ b/examples/preset_table_example.py @@ -0,0 +1,69 @@ +""" +Example showing how to use the ConfigPresetTableWidget. + +This widget provides a table view for editing a single ConfigGroup where: +- Columns represent presets in the group +- Rows represent device/property combinations +- Cells show the property values for each preset +""" + +from pymmcore_plus import CMMCorePlus +from pymmcore_plus.model import ConfigGroup +from qtpy.QtWidgets import ( + QApplication, + QComboBox, + QHBoxLayout, + QLabel, + QVBoxLayout, + QWidget, +) + +from pymmcore_widgets.config_presets._qmodel._config_model import QConfigGroupsModel +from pymmcore_widgets.config_presets._qmodel._preset_table import ( + ConfigPresetTableWidget, +) + +app = QApplication([]) + +# Initialize core and load config +core = CMMCorePlus() +core.loadSystemConfiguration() + +# Create the model +groups = core.getAvailableConfigGroups() +config_groups = [ConfigGroup.create_from_core(core, name) for name in groups] +model = QConfigGroupsModel(config_groups) + +# Create main widget +main_widget = QWidget() +layout = QVBoxLayout(main_widget) + +# Add group selector +group_layout = QHBoxLayout() +group_layout.addWidget(QLabel("Config Group:")) +group_selector = QComboBox() +group_selector.addItems(groups) +group_layout.addWidget(group_selector) +layout.addLayout(group_layout) + +# Create the preset table widget +preset_table = ConfigPresetTableWidget() +preset_table.setModel(model) + +if groups: + preset_table.setCurrentGroup(groups[0]) + + +@group_selector.currentTextChanged.connect +def _on_group_changed(group_name: str): + preset_table.setCurrentGroup(group_name) + + +layout.addWidget(preset_table) + +# Show the widget +main_widget.resize(800, 600) +main_widget.setWindowTitle("Config Preset Table Example") +main_widget.show() + +app.exec() diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py index 5f51137ce..aa6a88f67 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py @@ -4,14 +4,12 @@ from pymmcore_plus import DeviceType from pymmcore_plus.model import ConfigGroup -from qtpy.QtCore import QAbstractItemModel, QModelIndex, QSize, Qt, Signal +from qtpy.QtCore import QModelIndex, QSize, Qt, Signal from qtpy.QtWidgets import ( QGroupBox, QHBoxLayout, QLabel, QListView, - QStyledItemDelegate, - QStyleOptionViewItem, QToolBar, QVBoxLayout, QWidget, @@ -19,7 +17,7 @@ from superqt import QIconifyIcon from pymmcore_widgets._icons import ICONS -from pymmcore_widgets.device_properties import DevicePropertyTable, PropertyWidget +from pymmcore_widgets.device_properties import DevicePropertyTable from ._config_model import QConfigGroupsModel, _Node @@ -33,43 +31,6 @@ from qtpy.QtGui import QAction, QActionGroup -class PropertyValueDelegate(QStyledItemDelegate): - """Item delegate that uses a PropertyWidget for editing PropertySetting values.""" - - def createEditor( - self, parent: QWidget | None, option: QStyleOptionViewItem, index: QModelIndex - ) -> QWidget | None: - node = cast("_Node", index.internalPointer()) - if not (model := index.model()) or (index.column() != 2) or not node.is_setting: - return super().createEditor(parent, option, index) - - row = index.row() - device = model.data(index.sibling(row, 0)) - prop = model.data(index.sibling(row, 1)) - widget = PropertyWidget(device, prop, parent=parent, connect_core=False) - widget.valueChanged.connect(lambda: self.commitData.emit(widget)) - widget.setAutoFillBackground(True) - return widget - - def setEditorData(self, editor: QWidget | None, index: QModelIndex) -> None: - if (model := index.model()) and isinstance(editor, PropertyWidget): - data = model.data(index, Qt.ItemDataRole.EditRole) - editor.setValue(data) - else: - super().setEditorData(editor, index) - - def setModelData( - self, - editor: QWidget | None, - model: QAbstractItemModel | None, - index: QModelIndex, - ) -> None: - if model and isinstance(editor, PropertyWidget): - model.setData(index, editor.value(), Qt.ItemDataRole.EditRole) - else: - super().setModelData(editor, model, index) - - # ----------------------------------------------------------------------------- # High-level editor widget # ----------------------------------------------------------------------------- diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_preset_table.py b/src/pymmcore_widgets/config_presets/_qmodel/_preset_table.py new file mode 100644 index 000000000..c82262c30 --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_qmodel/_preset_table.py @@ -0,0 +1,489 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from qtpy.QtCore import QAbstractItemModel, QAbstractTableModel, QModelIndex, Qt, Signal +from qtpy.QtWidgets import ( + QAbstractItemView, + QPushButton, + QStyledItemDelegate, + QStyleOptionViewItem, + QTableView, + QToolBar, + QVBoxLayout, + QWidget, +) + +if TYPE_CHECKING: + from pymmcore_plus.model._config_group import ConfigGroup, ConfigPreset + + from ._config_model import QConfigGroupsModel + + +class PresetTableDelegate(QStyledItemDelegate): + """Custom delegate for preset table that uses PropertyWidget for editing.""" + + def createEditor( + self, + parent: QWidget | None, + option: QStyleOptionViewItem, + index: QModelIndex, + ) -> QWidget | None: + if not index.isValid(): + return super().createEditor(parent, option, index) + + model = cast("PresetTableModel", index.model()) + if not model: + return super().createEditor(parent, option, index) + + # Get device and property for this row + row = index.row() + if row >= len(model._device_prop_pairs): + return super().createEditor(parent, option, index) + + device, prop = model._device_prop_pairs[row] + + # Import PropertyWidget here to avoid circular imports + from pymmcore_widgets.device_properties import PropertyWidget + + widget = PropertyWidget(device, prop, parent=parent, connect_core=False) + widget.valueChanged.connect(lambda: self.commitData.emit(widget)) + widget.setAutoFillBackground(True) + return widget + + def setEditorData(self, editor: QWidget | None, index: QModelIndex) -> None: + # Import here to avoid circular imports + from pymmcore_widgets.device_properties import PropertyWidget + + if isinstance(editor, PropertyWidget) and index.model(): + model = index.model() + data = model.data(index, Qt.ItemDataRole.EditRole) + editor.setValue(data) + else: + super().setEditorData(editor, index) + + def setModelData( + self, + editor: QWidget | None, + model: QAbstractItemModel | None, + index: QModelIndex, + ) -> None: + # Import here to avoid circular imports + from pymmcore_widgets.device_properties import PropertyWidget + + if model and isinstance(editor, PropertyWidget): + model.setData(index, editor.value(), Qt.ItemDataRole.EditRole) + else: + super().setModelData(editor, model, index) + + +class PresetTableModel(QAbstractTableModel): + """Table model that presents a single ConfigGroup as a table. + + Columns represent presets, rows represent device/property combinations. + This model is designed to work with PropertyValueDelegate by providing + the expected data structure when queried. + """ + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._source_model: QConfigGroupsModel | None = None + self._group_index = QModelIndex() + self._config_group: ConfigGroup | None = None + self._presets: list[ConfigPreset] = [] + self._device_prop_pairs: list[tuple[str, str]] = [] + + def setSourceModel(self, model: QConfigGroupsModel) -> None: + """Set the source QConfigGroupsModel.""" + if self._source_model is not None: + self._source_model.dataChanged.disconnect() + self._source_model.modelReset.disconnect() + self._source_model.rowsInserted.disconnect() + self._source_model.rowsRemoved.disconnect() + + self._source_model = model + if model is not None: + model.dataChanged.connect(self._on_source_data_changed) + model.modelReset.connect(self._refresh_data) + model.rowsInserted.connect(self._refresh_data) + model.rowsRemoved.connect(self._refresh_data) + + def setGroupIndex(self, group_index: QModelIndex) -> None: + """Set the current group to display.""" + if not group_index.isValid() or self._source_model is None: + self._group_index = QModelIndex() + self._config_group = None + self._refresh_data() + return + + # Verify this is a group index + node = group_index.internalPointer() + if not hasattr(node, "is_group") or not node.is_group: + return + + self._group_index = group_index + self._config_group = cast("ConfigGroup", node.payload) + self._refresh_data() + + def _refresh_data(self) -> None: + """Rebuild internal data structures from the current group.""" + self.beginResetModel() + + if self._config_group is None: + self._presets = [] + self._device_prop_pairs = [] + else: + # Get all presets + self._presets = list(self._config_group.presets.values()) + + # Collect all unique device/property combinations + device_prop_set = set() + for preset in self._presets: + for setting in preset.settings: + device_prop_set.add((setting.device_name, setting.property_name)) + + self._device_prop_pairs = sorted(device_prop_set) + + self.endResetModel() + + def _on_source_data_changed( + self, top_left: QModelIndex, bottom_right: QModelIndex + ) -> None: + """Handle changes in the source model.""" + # If the change affects our group, refresh + if self._group_index.isValid() and self._affects_our_group( + top_left, bottom_right + ): + self._refresh_data() + + def _affects_our_group( + self, top_left: QModelIndex, bottom_right: QModelIndex + ) -> bool: + """Check if the changed indices affect our current group.""" + # For simplicity, refresh on any change for now + # Could be optimized to only refresh when our specific group is affected + return True + + def rowCount(self, parent: QModelIndex | None = None) -> int: + if parent is None: + parent = QModelIndex() + return len(self._device_prop_pairs) + + def columnCount(self, parent: QModelIndex | None = None) -> int: + if parent is None: + parent = QModelIndex() + return len(self._presets) + + def headerData( + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> Any: + if role != Qt.ItemDataRole.DisplayRole: + return None + + if orientation == Qt.Orientation.Horizontal: + # Column headers are preset names + if 0 <= section < len(self._presets): + return self._presets[section].name + elif orientation == Qt.Orientation.Vertical: + # Row headers are device/property combinations + if 0 <= section < len(self._device_prop_pairs): + device, prop = self._device_prop_pairs[section] + return f"{device}.{prop}" + + return None + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid(): + return None + + row = index.row() + col = index.column() + + if not ( + 0 <= row < len(self._device_prop_pairs) and 0 <= col < len(self._presets) + ): + return None + + device, prop = self._device_prop_pairs[row] + preset = self._presets[col] + + # Find the setting for this device/property in this preset + setting = None + for s in preset.settings: + if s.device_name == device and s.property_name == prop: + setting = s + break + + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): + return setting.property_value if setting else "" + + return None + + def setData( + self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole + ) -> bool: + if not index.isValid() or role != Qt.ItemDataRole.EditRole: + return False + + row = index.row() + col = index.column() + + if not ( + 0 <= row < len(self._device_prop_pairs) and 0 <= col < len(self._presets) + ): + return False + + device, prop = self._device_prop_pairs[row] + preset = self._presets[col] + + # Find or create the setting + setting_idx = None + for i, s in enumerate(preset.settings): + if s.device_name == device and s.property_name == prop: + setting_idx = i + break + + # Import Setting here to avoid circular imports + from pymmcore_plus.model._config_group import Setting + + new_setting = Setting(device, prop, str(value)) + + if setting_idx is not None: + # Update existing setting + preset.settings[setting_idx] = new_setting + else: + # Add new setting + preset.settings.append(new_setting) + + # Also need to update the source model if we can find the corresponding index + if self._source_model and self._group_index.isValid(): + # Find the preset index in the source model + for i in range(self._source_model.rowCount(self._group_index)): + preset_index = self._source_model.index(i, 0, self._group_index) + if self._source_model.data(preset_index) == preset.name: + # Find or create the setting index in the source model + for j in range(self._source_model.rowCount(preset_index)): + setting_model_index = self._source_model.index( + j, 0, preset_index + ) + if ( + self._source_model.data(setting_model_index) == device + and self._source_model.data( + setting_model_index.sibling(j, 1) + ) + == prop + ): + # Update the value in the source model + value_index = setting_model_index.sibling(j, 2) + self._source_model.setData(value_index, value) + break + break + + self.dataChanged.emit(index, index, [role]) + return True + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + if not index.isValid(): + return Qt.ItemFlag.NoItemFlags + return ( + Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsEnabled + | Qt.ItemFlag.ItemIsEditable + ) + + +class ConfigPresetTableWidget(QWidget): + """Widget for editing a single ConfigGroup as a table with presets as columns.""" + + currentGroupChanged = Signal(str) # group_name + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._source_model: QConfigGroupsModel | None = None + self._current_group_name = "" + + # Create toolbar + + # Preset operations + self._add_preset_btn = QPushButton("Add Preset") + self._duplicate_preset_btn = QPushButton("Duplicate Preset") + self._remove_preset_btn = QPushButton("Remove Preset") + + # Row operations + self._add_row_btn = QPushButton("Add Row") + self._remove_row_btn = QPushButton("Remove Row") + + self._toolbar = QToolBar() + self._toolbar.addWidget(self._add_preset_btn) + self._toolbar.addWidget(self._duplicate_preset_btn) + self._toolbar.addWidget(self._remove_preset_btn) + self._toolbar.addSeparator() + self._toolbar.addWidget(self._add_row_btn) + self._toolbar.addWidget(self._remove_row_btn) + + # Create table + self._table_model = PresetTableModel(self) + self._table_view = QTableView() + self._table_view.setModel(self._table_model) + self._table_view.setSelectionBehavior( + QAbstractItemView.SelectionBehavior.SelectRows + ) + + # Configure headers + + if h_header := self._table_view.horizontalHeader(): + h_header.setStretchLastSection(True) + + if v_header := self._table_view.verticalHeader(): + v_header.setDefaultSectionSize(25) + + # Set up PropertyValueDelegate for all columns since they all represent values + self._delegate = PresetTableDelegate(self._table_view) + self._table_view.setItemDelegate(self._delegate) + + # LAYOUT --------------------------------------------------- + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._toolbar) + layout.addWidget(self._table_view) + + # CONNECT SIGNALS --------------------------------------------------- + + self._add_preset_btn.clicked.connect(self._add_preset) + self._duplicate_preset_btn.clicked.connect(self._duplicate_preset) + self._remove_preset_btn.clicked.connect(self._remove_preset) + self._add_row_btn.clicked.connect(self._add_row) + self._remove_row_btn.clicked.connect(self._remove_row) + + def setModel(self, model: QConfigGroupsModel) -> None: + """Set the source QConfigGroupsModel.""" + self._source_model = model + self._table_model.setSourceModel(model) + + def setCurrentGroup(self, group_name: str) -> None: + """Set the current group by name.""" + if not self._source_model: + return + + # Find the group index + group_index = self._source_model.index_for_group(group_name) + if group_index.isValid(): + self.setCurrentGroupIndex(group_index) + + def setCurrentGroupIndex(self, group_index: QModelIndex) -> None: + """Set the current group by QModelIndex.""" + if not self._source_model or not group_index.isValid(): + return + + self._table_model.setGroupIndex(group_index) + + # Update current group name + new_group_name = self._source_model.data(group_index) + if new_group_name != self._current_group_name: + self._current_group_name = new_group_name + self.currentGroupChanged.emit(new_group_name) + + def currentGroupName(self) -> str: + """Get the current group name.""" + return self._current_group_name + + def _add_preset(self) -> None: + """Add a new preset to the current group.""" + if not self._source_model: + return + group_index = self._source_model.index_for_group(self._current_group_name) + if group_index.isValid(): + preset_index = self._source_model.add_preset(group_index) + if preset_index.isValid(): + self._table_model._refresh_data() + + def _duplicate_preset(self) -> None: + """Duplicate the selected preset.""" + if not self._source_model: + return + + # Get selected column (preset) + sel_model = self._table_view.selectionModel() + if not sel_model: + return + + selection = sel_model.selectedColumns() + if not selection: + return + + col = selection[0].column() + if col < 0 or col >= len(self._table_model._presets): + return + + group_index = self._source_model.index_for_group(self._current_group_name) + if not group_index.isValid(): + return + + preset_name = self._table_model._presets[col].name + preset_index = self._source_model.index_for_preset(preset_name, group_index) + if preset_index.isValid(): + self._source_model.duplicate_preset(preset_index) + self._table_model._refresh_data() + + def _remove_preset(self) -> None: + """Remove the selected preset.""" + if not self._source_model: + return + + # Get selected column (preset) + sel_model = self._table_view.selectionModel() + if not sel_model: + return + + selection = sel_model.selectedColumns() + if not selection: + return + + col = selection[0].column() + if col < 0 or col >= len(self._table_model._presets): + return + + group_index = self._source_model.index_for_group(self._current_group_name) + if not group_index.isValid(): + return + + preset_name = self._table_model._presets[col].name + preset_index = self._source_model.index_for_preset(preset_name, group_index) + if preset_index.isValid(): + self._source_model.remove(preset_index) + self._table_model._refresh_data() + + def _add_row(self) -> None: + """Add a new device/property row.""" + # This will need custom logic - placeholder for now + pass + + def _remove_row(self) -> None: + """Remove the selected device/property row from all presets.""" + sel_model = self._table_view.selectionModel() + if not sel_model: + return + + selection = sel_model.selectedRows() + if not selection: + return + + row = selection[0].row() + if row < 0 or row >= len(self._table_model._device_prop_pairs): + return + + device, prop = self._table_model._device_prop_pairs[row] + + # Remove this device/property from all presets in the group + for preset in self._table_model._presets: + preset.settings = [ + s + for s in preset.settings + if not (s.device_name == device and s.property_name == prop) + ] + + # Refresh the model + self._table_model._refresh_data() diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_property_value_delegate.py b/src/pymmcore_widgets/config_presets/_qmodel/_property_value_delegate.py new file mode 100644 index 000000000..b90da3188 --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_qmodel/_property_value_delegate.py @@ -0,0 +1,46 @@ +from typing import TYPE_CHECKING, cast + +from qtpy.QtCore import QAbstractItemModel, QModelIndex, Qt +from qtpy.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem, QWidget + +from pymmcore_widgets.device_properties import PropertyWidget + +if TYPE_CHECKING: + from pymmcore_widgets.config_presets._qmodel._config_model import _Node + + +class PropertyValueDelegate(QStyledItemDelegate): + """Item delegate that uses a PropertyWidget for editing PropertySetting values.""" + + def createEditor( + self, parent: QWidget | None, option: QStyleOptionViewItem, index: QModelIndex + ) -> QWidget | None: + node = cast("_Node", index.internalPointer()) + if not (model := index.model()) or (index.column() != 2) or not node.is_setting: + return super().createEditor(parent, option, index) + + row = index.row() + device = model.data(index.sibling(row, 0)) + prop = model.data(index.sibling(row, 1)) + widget = PropertyWidget(device, prop, parent=parent, connect_core=False) + widget.valueChanged.connect(lambda: self.commitData.emit(widget)) + widget.setAutoFillBackground(True) + return widget + + def setEditorData(self, editor: QWidget | None, index: QModelIndex) -> None: + if (model := index.model()) and isinstance(editor, PropertyWidget): + data = model.data(index, Qt.ItemDataRole.EditRole) + editor.setValue(data) + else: + super().setEditorData(editor, index) + + def setModelData( + self, + editor: QWidget | None, + model: QAbstractItemModel | None, + index: QModelIndex, + ) -> None: + if model and isinstance(editor, PropertyWidget): + model.setData(index, editor.value(), Qt.ItemDataRole.EditRole) + else: + super().setModelData(editor, model, index) From eacc052158ea7121d1677d436da2d300f977aaa9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 28 Jun 2025 15:53:11 -0400 Subject: [PATCH 19/70] better table --- examples/config_groups_editor.py | 20 +- examples/preset_table_example.py | 69 --- .../config_presets/_qmodel/_config_model.py | 65 ++- .../config_presets/_qmodel/_config_views.py | 10 +- .../config_presets/_qmodel/_preset_table.py | 489 ------------------ .../config_presets/_qmodel/_presets_table.py | 224 ++++++++ ...egate.py => _property_setting_delegate.py} | 34 +- 7 files changed, 314 insertions(+), 597 deletions(-) delete mode 100644 examples/preset_table_example.py delete mode 100644 src/pymmcore_widgets/config_presets/_qmodel/_preset_table.py create mode 100644 src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py rename src/pymmcore_widgets/config_presets/_qmodel/{_property_value_delegate.py => _property_setting_delegate.py} (53%) diff --git a/examples/config_groups_editor.py b/examples/config_groups_editor.py index 641166d1a..d04daf8e5 100644 --- a/examples/config_groups_editor.py +++ b/examples/config_groups_editor.py @@ -1,10 +1,10 @@ from pymmcore_plus import CMMCorePlus from qtpy.QtCore import QModelIndex -from qtpy.QtWidgets import QApplication, QHBoxLayout, QTreeView, QWidget +from qtpy.QtWidgets import QApplication, QSplitter, QTreeView from pymmcore_widgets import ConfigGroupsEditor -from pymmcore_widgets.config_presets._qmodel._property_value_delegate import ( - PropertyValueDelegate, +from pymmcore_widgets.config_presets._qmodel._property_setting_delegate import ( + PropertySettingDelegate, ) app = QApplication([]) @@ -20,14 +20,14 @@ tree.expandRecursively(QModelIndex()) tree.setColumnWidth(0, 180) # make values in in the tree editable -tree.setItemDelegateForColumn(2, PropertyValueDelegate(tree)) +tree.setItemDelegateForColumn(2, PropertySettingDelegate(tree)) -w = QWidget() -layout = QHBoxLayout(w) -layout.addWidget(cfg) -layout.addWidget(tree) -w.resize(1400, 800) -w.show() +splitter = QSplitter() +splitter.addWidget(cfg) +splitter.addWidget(tree) +splitter.resize(1400, 800) +splitter.setSizes([900, 500]) +splitter.show() app.exec() diff --git a/examples/preset_table_example.py b/examples/preset_table_example.py deleted file mode 100644 index 711c13bf0..000000000 --- a/examples/preset_table_example.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -Example showing how to use the ConfigPresetTableWidget. - -This widget provides a table view for editing a single ConfigGroup where: -- Columns represent presets in the group -- Rows represent device/property combinations -- Cells show the property values for each preset -""" - -from pymmcore_plus import CMMCorePlus -from pymmcore_plus.model import ConfigGroup -from qtpy.QtWidgets import ( - QApplication, - QComboBox, - QHBoxLayout, - QLabel, - QVBoxLayout, - QWidget, -) - -from pymmcore_widgets.config_presets._qmodel._config_model import QConfigGroupsModel -from pymmcore_widgets.config_presets._qmodel._preset_table import ( - ConfigPresetTableWidget, -) - -app = QApplication([]) - -# Initialize core and load config -core = CMMCorePlus() -core.loadSystemConfiguration() - -# Create the model -groups = core.getAvailableConfigGroups() -config_groups = [ConfigGroup.create_from_core(core, name) for name in groups] -model = QConfigGroupsModel(config_groups) - -# Create main widget -main_widget = QWidget() -layout = QVBoxLayout(main_widget) - -# Add group selector -group_layout = QHBoxLayout() -group_layout.addWidget(QLabel("Config Group:")) -group_selector = QComboBox() -group_selector.addItems(groups) -group_layout.addWidget(group_selector) -layout.addLayout(group_layout) - -# Create the preset table widget -preset_table = ConfigPresetTableWidget() -preset_table.setModel(model) - -if groups: - preset_table.setCurrentGroup(groups[0]) - - -@group_selector.currentTextChanged.connect -def _on_group_changed(group_name: str): - preset_table.setCurrentGroup(group_name) - - -layout.addWidget(preset_table) - -# Show the widget -main_widget.resize(800, 600) -main_widget.setWindowTitle("Config Preset Table Example") -main_widget.show() - -app.exec() diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py index 079aafdb2..d0072c899 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py @@ -23,6 +23,8 @@ if TYPE_CHECKING: from collections.abc import Iterable + from typing_extensions import Self + class Col(IntEnum): """Column indices for the ConfigTreeModel.""" @@ -69,6 +71,10 @@ def is_setting(self) -> bool: class QConfigGroupsModel(QAbstractItemModel): """Three-level model: root → groups → presets → settings.""" + @classmethod + def create_from_core(cls, core: CMMCorePlus) -> Self: + return cls(ConfigGroup.all_config_groups(core).values()) + def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: super().__init__() self._root = _Node("", None) @@ -87,9 +93,16 @@ def index_for_group(self, group_name: str) -> QModelIndex: return QModelIndex() def index_for_preset( - self, preset_name: str, group_index: QModelIndex + self, group: QModelIndex | str, preset_name: str ) -> QModelIndex: """Return the QModelIndex for the preset with the given name in the group.""" + if isinstance(group, QModelIndex): + if not self._is_group_index(group): + return QModelIndex() + group_index = group + else: + group_index = self.index_for_group(group) + if not self._is_group_index(group_index): return QModelIndex() parent_node = cast("_Node", group_index.internalPointer()) @@ -107,13 +120,15 @@ def add_group(self, base_name: str = "Group") -> QModelIndex: node = _Node(name, group, self._root) return self._insert_node(node, self._root, len(self._root.children)) - def duplicate_group(self, idx: QModelIndex) -> QModelIndex: + def duplicate_group( + self, idx: QModelIndex, new_name: str | None = None + ) -> QModelIndex: if not self._is_group_index(idx): return QModelIndex() node = cast("_Node", idx.internalPointer()) new_grp = deepcopy(node.payload) assert isinstance(new_grp, ConfigGroup) - new_grp.name = self._unique_child_name(self._root, new_grp.name) + new_grp.name = new_name or self._unique_child_name(self._root, new_grp.name) node = _Node(new_grp.name, new_grp, self._root) # duplicate presets for p in new_grp.presets.values(): @@ -134,14 +149,18 @@ def add_preset( node = _Node(name, preset, parent_node) return self._insert_node(node, parent_node, len(parent_node.children)) - def duplicate_preset(self, idx: QModelIndex) -> QModelIndex: + def duplicate_preset( + self, idx: QModelIndex, new_name: str | None = None + ) -> QModelIndex: if not self._is_preset_index(idx): return QModelIndex() parent_node = cast("_Node", idx.parent().internalPointer()) orig = cast("_Node", idx.internalPointer()) new_preset = deepcopy(orig.payload) assert isinstance(new_preset, ConfigPreset) - new_preset.name = self._unique_child_name(parent_node, new_preset.name) + new_preset.name = new_name or self._unique_child_name( + parent_node, new_preset.name + ) node = _Node(new_preset.name, new_preset, parent_node) return self._insert_node(node, parent_node, idx.row() + 1) @@ -203,7 +222,13 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A if not index.isValid(): return None - node = cast("_Node", index.internalPointer()) + if not isinstance(node := index.internalPointer(), _Node): + return None + + if role == Qt.ItemDataRole.UserRole: + # return the node itself for easy access in views + return node.payload + if role == Qt.ItemDataRole.FontRole and index.column() == Col.Item: f = QFont() if node.is_group: @@ -370,10 +395,14 @@ def _unique_child_name(parent: _Node, base: str) -> str: names = {c.name for c in parent.children} if base not in names: return base - i = 1 - while f"{base} {i}" in names: - i += 1 - return f"{base} {i}" + # try 'base copy' ... but then resort to 'base copy(n)' if needed + if (name := f"{base} copy") not in names: + return name + n = 1 + while name in names: + name = f"{base} copy ({n})" + n += 1 + return name @staticmethod def _name_exists(parent: _Node | None, name: str) -> bool: @@ -394,6 +423,22 @@ def _is_preset_index(idx: QModelIndex) -> bool: def _insert_node(self, node: _Node, parent_node: _Node, row: int) -> QModelIndex: self.beginInsertRows(self._index_from_node(parent_node), row, row) parent_node.children.insert(row, node) + if parent_node.is_group and node.is_preset: + # update the python model too + if isinstance((group := parent_node.payload), ConfigGroup): + # recreate group.presets so that node.name lands at row index: + presets = list(group.presets.values()) + presets.insert(row, cast("ConfigPreset", node.payload)) + group.presets = {p.name: p for p in presets} + + elif parent_node.is_preset and node.is_setting: + # update the python model too + if isinstance((preset := parent_node.payload), ConfigPreset): + # recreate preset.settings so that node.name lands at row index: + settings = list(preset.settings) + settings.insert(row, cast("Setting", node.payload)) + preset.settings = settings + self.endInsertRows() return self.createIndex(row, 0, node) diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py index aa6a88f67..df703b0fc 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py @@ -17,6 +17,7 @@ from superqt import QIconifyIcon from pymmcore_widgets._icons import ICONS +from pymmcore_widgets.config_presets._qmodel._presets_table import PresetsTable from pymmcore_widgets.device_properties import DevicePropertyTable from ._config_model import QConfigGroupsModel, _Node @@ -183,6 +184,7 @@ def __init__(self, parent: QWidget | None = None) -> None: preset_box.add_action.triggered.connect(self._new_preset) self._props = _PropSettings(self) + self._props._presets_table.setModel(self._model) # layout ------------------------------------------------------------ @@ -195,7 +197,7 @@ def __init__(self, parent: QWidget | None = None) -> None: lay = QHBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.addWidget(left) - lay.addWidget(self._props) + lay.addWidget(self._props, 1) # signals ------------------------------------------------------------ @@ -222,7 +224,7 @@ def setCurrentPreset(self, group: str, preset: str) -> None: """Set the currently selected preset in the editor.""" self.setCurrentGroup(group) group_index = self._model.index_for_group(group) - idx = self._model.index_for_preset(preset, group_index) + idx = self._model.index_for_preset(group_index, preset) if idx.isValid(): self._preset_view.setCurrentIndex(idx) else: @@ -265,6 +267,7 @@ def _new_preset(self) -> None: def _on_group_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: self._preset_view.setRootIndex(current) + self._props._presets_table.setGroup(current) if current.isValid() and self._model.rowCount(current): self._preset_view.setCurrentIndex(self._model.index(0, 0, current)) else: @@ -330,6 +333,8 @@ class _PropSettings(QWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) + self._presets_table = PresetsTable(self) + self._action_group = QActionGroup(self) self._action_group.setExclusive(False) self._prop_tables = DevicePropertyTable() @@ -338,6 +343,7 @@ def __init__(self, parent: QWidget | None = None) -> None: tb, self._action_group = self._create_device_buttons() rv = QVBoxLayout(self) rv.setContentsMargins(0, 0, 0, 0) + rv.addWidget(self._presets_table) rv.addWidget(tb) rv.addWidget(self._prop_tables) diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_preset_table.py b/src/pymmcore_widgets/config_presets/_qmodel/_preset_table.py deleted file mode 100644 index c82262c30..000000000 --- a/src/pymmcore_widgets/config_presets/_qmodel/_preset_table.py +++ /dev/null @@ -1,489 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, cast - -from qtpy.QtCore import QAbstractItemModel, QAbstractTableModel, QModelIndex, Qt, Signal -from qtpy.QtWidgets import ( - QAbstractItemView, - QPushButton, - QStyledItemDelegate, - QStyleOptionViewItem, - QTableView, - QToolBar, - QVBoxLayout, - QWidget, -) - -if TYPE_CHECKING: - from pymmcore_plus.model._config_group import ConfigGroup, ConfigPreset - - from ._config_model import QConfigGroupsModel - - -class PresetTableDelegate(QStyledItemDelegate): - """Custom delegate for preset table that uses PropertyWidget for editing.""" - - def createEditor( - self, - parent: QWidget | None, - option: QStyleOptionViewItem, - index: QModelIndex, - ) -> QWidget | None: - if not index.isValid(): - return super().createEditor(parent, option, index) - - model = cast("PresetTableModel", index.model()) - if not model: - return super().createEditor(parent, option, index) - - # Get device and property for this row - row = index.row() - if row >= len(model._device_prop_pairs): - return super().createEditor(parent, option, index) - - device, prop = model._device_prop_pairs[row] - - # Import PropertyWidget here to avoid circular imports - from pymmcore_widgets.device_properties import PropertyWidget - - widget = PropertyWidget(device, prop, parent=parent, connect_core=False) - widget.valueChanged.connect(lambda: self.commitData.emit(widget)) - widget.setAutoFillBackground(True) - return widget - - def setEditorData(self, editor: QWidget | None, index: QModelIndex) -> None: - # Import here to avoid circular imports - from pymmcore_widgets.device_properties import PropertyWidget - - if isinstance(editor, PropertyWidget) and index.model(): - model = index.model() - data = model.data(index, Qt.ItemDataRole.EditRole) - editor.setValue(data) - else: - super().setEditorData(editor, index) - - def setModelData( - self, - editor: QWidget | None, - model: QAbstractItemModel | None, - index: QModelIndex, - ) -> None: - # Import here to avoid circular imports - from pymmcore_widgets.device_properties import PropertyWidget - - if model and isinstance(editor, PropertyWidget): - model.setData(index, editor.value(), Qt.ItemDataRole.EditRole) - else: - super().setModelData(editor, model, index) - - -class PresetTableModel(QAbstractTableModel): - """Table model that presents a single ConfigGroup as a table. - - Columns represent presets, rows represent device/property combinations. - This model is designed to work with PropertyValueDelegate by providing - the expected data structure when queried. - """ - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self._source_model: QConfigGroupsModel | None = None - self._group_index = QModelIndex() - self._config_group: ConfigGroup | None = None - self._presets: list[ConfigPreset] = [] - self._device_prop_pairs: list[tuple[str, str]] = [] - - def setSourceModel(self, model: QConfigGroupsModel) -> None: - """Set the source QConfigGroupsModel.""" - if self._source_model is not None: - self._source_model.dataChanged.disconnect() - self._source_model.modelReset.disconnect() - self._source_model.rowsInserted.disconnect() - self._source_model.rowsRemoved.disconnect() - - self._source_model = model - if model is not None: - model.dataChanged.connect(self._on_source_data_changed) - model.modelReset.connect(self._refresh_data) - model.rowsInserted.connect(self._refresh_data) - model.rowsRemoved.connect(self._refresh_data) - - def setGroupIndex(self, group_index: QModelIndex) -> None: - """Set the current group to display.""" - if not group_index.isValid() or self._source_model is None: - self._group_index = QModelIndex() - self._config_group = None - self._refresh_data() - return - - # Verify this is a group index - node = group_index.internalPointer() - if not hasattr(node, "is_group") or not node.is_group: - return - - self._group_index = group_index - self._config_group = cast("ConfigGroup", node.payload) - self._refresh_data() - - def _refresh_data(self) -> None: - """Rebuild internal data structures from the current group.""" - self.beginResetModel() - - if self._config_group is None: - self._presets = [] - self._device_prop_pairs = [] - else: - # Get all presets - self._presets = list(self._config_group.presets.values()) - - # Collect all unique device/property combinations - device_prop_set = set() - for preset in self._presets: - for setting in preset.settings: - device_prop_set.add((setting.device_name, setting.property_name)) - - self._device_prop_pairs = sorted(device_prop_set) - - self.endResetModel() - - def _on_source_data_changed( - self, top_left: QModelIndex, bottom_right: QModelIndex - ) -> None: - """Handle changes in the source model.""" - # If the change affects our group, refresh - if self._group_index.isValid() and self._affects_our_group( - top_left, bottom_right - ): - self._refresh_data() - - def _affects_our_group( - self, top_left: QModelIndex, bottom_right: QModelIndex - ) -> bool: - """Check if the changed indices affect our current group.""" - # For simplicity, refresh on any change for now - # Could be optimized to only refresh when our specific group is affected - return True - - def rowCount(self, parent: QModelIndex | None = None) -> int: - if parent is None: - parent = QModelIndex() - return len(self._device_prop_pairs) - - def columnCount(self, parent: QModelIndex | None = None) -> int: - if parent is None: - parent = QModelIndex() - return len(self._presets) - - def headerData( - self, - section: int, - orientation: Qt.Orientation, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if role != Qt.ItemDataRole.DisplayRole: - return None - - if orientation == Qt.Orientation.Horizontal: - # Column headers are preset names - if 0 <= section < len(self._presets): - return self._presets[section].name - elif orientation == Qt.Orientation.Vertical: - # Row headers are device/property combinations - if 0 <= section < len(self._device_prop_pairs): - device, prop = self._device_prop_pairs[section] - return f"{device}.{prop}" - - return None - - def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: - if not index.isValid(): - return None - - row = index.row() - col = index.column() - - if not ( - 0 <= row < len(self._device_prop_pairs) and 0 <= col < len(self._presets) - ): - return None - - device, prop = self._device_prop_pairs[row] - preset = self._presets[col] - - # Find the setting for this device/property in this preset - setting = None - for s in preset.settings: - if s.device_name == device and s.property_name == prop: - setting = s - break - - if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - return setting.property_value if setting else "" - - return None - - def setData( - self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole - ) -> bool: - if not index.isValid() or role != Qt.ItemDataRole.EditRole: - return False - - row = index.row() - col = index.column() - - if not ( - 0 <= row < len(self._device_prop_pairs) and 0 <= col < len(self._presets) - ): - return False - - device, prop = self._device_prop_pairs[row] - preset = self._presets[col] - - # Find or create the setting - setting_idx = None - for i, s in enumerate(preset.settings): - if s.device_name == device and s.property_name == prop: - setting_idx = i - break - - # Import Setting here to avoid circular imports - from pymmcore_plus.model._config_group import Setting - - new_setting = Setting(device, prop, str(value)) - - if setting_idx is not None: - # Update existing setting - preset.settings[setting_idx] = new_setting - else: - # Add new setting - preset.settings.append(new_setting) - - # Also need to update the source model if we can find the corresponding index - if self._source_model and self._group_index.isValid(): - # Find the preset index in the source model - for i in range(self._source_model.rowCount(self._group_index)): - preset_index = self._source_model.index(i, 0, self._group_index) - if self._source_model.data(preset_index) == preset.name: - # Find or create the setting index in the source model - for j in range(self._source_model.rowCount(preset_index)): - setting_model_index = self._source_model.index( - j, 0, preset_index - ) - if ( - self._source_model.data(setting_model_index) == device - and self._source_model.data( - setting_model_index.sibling(j, 1) - ) - == prop - ): - # Update the value in the source model - value_index = setting_model_index.sibling(j, 2) - self._source_model.setData(value_index, value) - break - break - - self.dataChanged.emit(index, index, [role]) - return True - - def flags(self, index: QModelIndex) -> Qt.ItemFlag: - if not index.isValid(): - return Qt.ItemFlag.NoItemFlags - return ( - Qt.ItemFlag.ItemIsSelectable - | Qt.ItemFlag.ItemIsEnabled - | Qt.ItemFlag.ItemIsEditable - ) - - -class ConfigPresetTableWidget(QWidget): - """Widget for editing a single ConfigGroup as a table with presets as columns.""" - - currentGroupChanged = Signal(str) # group_name - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self._source_model: QConfigGroupsModel | None = None - self._current_group_name = "" - - # Create toolbar - - # Preset operations - self._add_preset_btn = QPushButton("Add Preset") - self._duplicate_preset_btn = QPushButton("Duplicate Preset") - self._remove_preset_btn = QPushButton("Remove Preset") - - # Row operations - self._add_row_btn = QPushButton("Add Row") - self._remove_row_btn = QPushButton("Remove Row") - - self._toolbar = QToolBar() - self._toolbar.addWidget(self._add_preset_btn) - self._toolbar.addWidget(self._duplicate_preset_btn) - self._toolbar.addWidget(self._remove_preset_btn) - self._toolbar.addSeparator() - self._toolbar.addWidget(self._add_row_btn) - self._toolbar.addWidget(self._remove_row_btn) - - # Create table - self._table_model = PresetTableModel(self) - self._table_view = QTableView() - self._table_view.setModel(self._table_model) - self._table_view.setSelectionBehavior( - QAbstractItemView.SelectionBehavior.SelectRows - ) - - # Configure headers - - if h_header := self._table_view.horizontalHeader(): - h_header.setStretchLastSection(True) - - if v_header := self._table_view.verticalHeader(): - v_header.setDefaultSectionSize(25) - - # Set up PropertyValueDelegate for all columns since they all represent values - self._delegate = PresetTableDelegate(self._table_view) - self._table_view.setItemDelegate(self._delegate) - - # LAYOUT --------------------------------------------------- - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._toolbar) - layout.addWidget(self._table_view) - - # CONNECT SIGNALS --------------------------------------------------- - - self._add_preset_btn.clicked.connect(self._add_preset) - self._duplicate_preset_btn.clicked.connect(self._duplicate_preset) - self._remove_preset_btn.clicked.connect(self._remove_preset) - self._add_row_btn.clicked.connect(self._add_row) - self._remove_row_btn.clicked.connect(self._remove_row) - - def setModel(self, model: QConfigGroupsModel) -> None: - """Set the source QConfigGroupsModel.""" - self._source_model = model - self._table_model.setSourceModel(model) - - def setCurrentGroup(self, group_name: str) -> None: - """Set the current group by name.""" - if not self._source_model: - return - - # Find the group index - group_index = self._source_model.index_for_group(group_name) - if group_index.isValid(): - self.setCurrentGroupIndex(group_index) - - def setCurrentGroupIndex(self, group_index: QModelIndex) -> None: - """Set the current group by QModelIndex.""" - if not self._source_model or not group_index.isValid(): - return - - self._table_model.setGroupIndex(group_index) - - # Update current group name - new_group_name = self._source_model.data(group_index) - if new_group_name != self._current_group_name: - self._current_group_name = new_group_name - self.currentGroupChanged.emit(new_group_name) - - def currentGroupName(self) -> str: - """Get the current group name.""" - return self._current_group_name - - def _add_preset(self) -> None: - """Add a new preset to the current group.""" - if not self._source_model: - return - group_index = self._source_model.index_for_group(self._current_group_name) - if group_index.isValid(): - preset_index = self._source_model.add_preset(group_index) - if preset_index.isValid(): - self._table_model._refresh_data() - - def _duplicate_preset(self) -> None: - """Duplicate the selected preset.""" - if not self._source_model: - return - - # Get selected column (preset) - sel_model = self._table_view.selectionModel() - if not sel_model: - return - - selection = sel_model.selectedColumns() - if not selection: - return - - col = selection[0].column() - if col < 0 or col >= len(self._table_model._presets): - return - - group_index = self._source_model.index_for_group(self._current_group_name) - if not group_index.isValid(): - return - - preset_name = self._table_model._presets[col].name - preset_index = self._source_model.index_for_preset(preset_name, group_index) - if preset_index.isValid(): - self._source_model.duplicate_preset(preset_index) - self._table_model._refresh_data() - - def _remove_preset(self) -> None: - """Remove the selected preset.""" - if not self._source_model: - return - - # Get selected column (preset) - sel_model = self._table_view.selectionModel() - if not sel_model: - return - - selection = sel_model.selectedColumns() - if not selection: - return - - col = selection[0].column() - if col < 0 or col >= len(self._table_model._presets): - return - - group_index = self._source_model.index_for_group(self._current_group_name) - if not group_index.isValid(): - return - - preset_name = self._table_model._presets[col].name - preset_index = self._source_model.index_for_preset(preset_name, group_index) - if preset_index.isValid(): - self._source_model.remove(preset_index) - self._table_model._refresh_data() - - def _add_row(self) -> None: - """Add a new device/property row.""" - # This will need custom logic - placeholder for now - pass - - def _remove_row(self) -> None: - """Remove the selected device/property row from all presets.""" - sel_model = self._table_view.selectionModel() - if not sel_model: - return - - selection = sel_model.selectedRows() - if not selection: - return - - row = selection[0].row() - if row < 0 or row >= len(self._table_model._device_prop_pairs): - return - - device, prop = self._table_model._device_prop_pairs[row] - - # Remove this device/property from all presets in the group - for preset in self._table_model._presets: - preset.settings = [ - s - for s in preset.settings - if not (s.device_name == device and s.property_name == prop) - ] - - # Refresh the model - self._table_model._refresh_data() diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py b/src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py new file mode 100644 index 000000000..efc483ca0 --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +import numpy as np +from pymmcore_plus.model import ConfigPreset, Setting +from qtpy.QtCore import ( + QAbstractTableModel, + QModelIndex, + QSize, + Qt, + QTransposeProxyModel, +) +from qtpy.QtWidgets import QTableView, QToolBar, QVBoxLayout, QWidget +from superqt import QIconifyIcon + +from ._config_model import QConfigGroupsModel, _Node +from ._property_setting_delegate import PropertySettingDelegate + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + from pymmcore_plus.model import ConfigPreset + + +# ----------------------------------------------------------------------------- +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) + # NDArray[Setting | None] for quick index-based lookup + self._data: np.ndarray = np.empty((0, 0), dtype=object) + self._root = _Node("", None) + + 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): + 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: + 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(): + raise ValueError("Invalid QModelIndex provided for group selection.") + self._gidx = group_name_or_index + self._rebuild() + + # ---------------------------------------------------------------- build -- + + def _rebuild(self) -> None: # slot signature is flexible + if self._gidx is None: # nothing selected yet + return + 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 = np.empty((len(self._rows), len(self._presets)), dtype=object) + for col, preset in enumerate(self._presets): + for row, (device, prop) in enumerate(self._rows): + # Find the setting for this device/prop in the preset + for s in preset.settings: + if (s.device_name, s.property_name) == (device, prop): + self._data[row, col] = s + break + else: + self._data[row, col] = None + + self.endResetModel() + + # --------------------------------------------------------- Qt overrides -- + + def rowCount(self, parent: QModelIndex | None = None) -> int: + return len(self._rows) + + def columnCount(self, parent: QModelIndex | None = None) -> int: + return len(self._presets) + + def headerData( + self, + section: int, + orient: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> Any: + if role != Qt.ItemDataRole.DisplayRole: + return None + if orient == Qt.Orientation.Horizontal: + return self._presets[section].name + return "-".join(self._rows[section]) + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid(): + return None + + if not isinstance(setting := self._data[index.row(), index.column()], Setting): + 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(): + return Qt.ItemFlag.NoItemFlags + return ( + Qt.ItemFlag.ItemIsEnabled + | Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsEditable + ) + + +class PresetsTable(QWidget): + """A simple table widget to display presets.""" + + @classmethod + def create_from_core( + cls, core: CMMCorePlus, parent: QWidget | None = None + ) -> PresetsTable: + """Create a PresetsTable from a CMMCorePlus instance.""" + obj = cls(parent) + model = QConfigGroupsModel.create_from_core(core) + obj.setModel(model) + return obj + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.table_view = QTableView(self) + self.table_view.setItemDelegate(PropertySettingDelegate(self.table_view)) + + self._toolbar = tb = QToolBar(self) + tb.setIconSize(QSize(16, 16)) + tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + if act := tb.addAction( + QIconifyIcon("carbon:transpose"), "Transpose", self._transpose + ): + act.setCheckable(True) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._toolbar) + layout.addWidget(self.table_view) + + def _update_view(self) -> None: + matrix = self.table_view.model() + if not isinstance(matrix, _ConfigGroupPivotModel): + return + + if hh := self.table_view.horizontalHeader(): + hh.setSectionResizeMode(hh.ResizeMode.Stretch) + + def _transpose(self) -> None: + """Transpose the table view.""" + pivot = self.table_view.model() + if isinstance(pivot, _ConfigGroupPivotModel): + proxy = QTransposeProxyModel(self) + proxy.setSourceModel(pivot) + self.table_view.setModel(proxy) + elif isinstance(pivot, QTransposeProxyModel): + # Already transposed, revert to original model + source_model = pivot.sourceModel() + if isinstance(source_model, _ConfigGroupPivotModel): + self.table_view.setModel(source_model) + + def sourceModel(self) -> QConfigGroupsModel | None: + """Return the source model of the table view.""" + model = self.table_view.model() + if isinstance(model, QTransposeProxyModel): + model = cast("_ConfigGroupPivotModel", model.sourceModel()) + if isinstance(model, _ConfigGroupPivotModel): + return model.sourceModel() + return None + + def setModel(self, model: QConfigGroupsModel | _ConfigGroupPivotModel) -> None: + """Set the model for the table view.""" + if isinstance(model, QConfigGroupsModel): + matrix = _ConfigGroupPivotModel() + matrix.setSourceModel(model) + elif isinstance(model, _ConfigGroupPivotModel): + matrix = model + else: + raise TypeError( + "Model must be an instance of QConfigGroupsModel " + "or ConfigGroupPivotModel." + ) + + self.table_view.setModel(matrix) + matrix.modelReset.connect(self._update_view) + + def setGroup(self, group_name_or_index: str | QModelIndex) -> None: + """Set the group for the pivot model.""" + model = self.table_view.model() + if isinstance(model, QTransposeProxyModel): + model = cast("_ConfigGroupPivotModel", model.sourceModel()) + if not isinstance(model, _ConfigGroupPivotModel): + raise ValueError("Source model is not set. Call setSourceModel first.") + model.setGroup(group_name_or_index) diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_property_value_delegate.py b/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py similarity index 53% rename from src/pymmcore_widgets/config_presets/_qmodel/_property_value_delegate.py rename to src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py index b90da3188..b17c5834e 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_property_value_delegate.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py @@ -1,38 +1,38 @@ -from typing import TYPE_CHECKING, cast - +from pymmcore_plus.model import Setting from qtpy.QtCore import QAbstractItemModel, QModelIndex, Qt from qtpy.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem, QWidget from pymmcore_widgets.device_properties import PropertyWidget -if TYPE_CHECKING: - from pymmcore_widgets.config_presets._qmodel._config_model import _Node - -class PropertyValueDelegate(QStyledItemDelegate): +class PropertySettingDelegate(QStyledItemDelegate): """Item delegate that uses a PropertyWidget for editing PropertySetting values.""" def createEditor( self, parent: QWidget | None, option: QStyleOptionViewItem, index: QModelIndex ) -> QWidget | None: - node = cast("_Node", index.internalPointer()) - if not (model := index.model()) or (index.column() != 2) or not node.is_setting: + if not isinstance((setting := index.data(Qt.ItemDataRole.UserRole)), Setting): return super().createEditor(parent, option, index) + dev, prop, *_ = setting + widget = PropertyWidget(dev, prop, parent=parent, connect_core=False) + + # For persistent editors, we connect valueChanged to setModelData directly + # instead of commitData to avoid the "editor doesn't belong to view" error + def on_value_changed() -> None: + if index.isValid() and (model := index.model()): + model.setData(index, widget.value(), Qt.ItemDataRole.EditRole) - row = index.row() - device = model.data(index.sibling(row, 0)) - prop = model.data(index.sibling(row, 1)) - widget = PropertyWidget(device, prop, parent=parent, connect_core=False) - widget.valueChanged.connect(lambda: self.commitData.emit(widget)) + widget.valueChanged.connect(on_value_changed) widget.setAutoFillBackground(True) return widget def setEditorData(self, editor: QWidget | None, index: QModelIndex) -> None: - if (model := index.model()) and isinstance(editor, PropertyWidget): - data = model.data(index, Qt.ItemDataRole.EditRole) - editor.setValue(data) - else: + setting = index.data(Qt.ItemDataRole.UserRole) + if not isinstance(setting, Setting) or not isinstance(editor, PropertyWidget): super().setEditorData(editor, index) + return + + editor.setValue(setting.property_value) def setModelData( self, From 6f25cb550761b2ce36425470a5b36d20fe9d3dcc Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 28 Jun 2025 16:20:47 -0400 Subject: [PATCH 20/70] better table --- .../config_presets/_qmodel/_presets_table.py | 56 ++++++++++++++++--- .../_qmodel/_property_setting_delegate.py | 9 +-- src/pymmcore_widgets/control/_rois/_vispy.py | 10 +--- .../_stage_explorer/_stage_explorer.py | 1 - 4 files changed, 51 insertions(+), 25 deletions(-) diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py b/src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py index efc483ca0..b7d4b3cba 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py @@ -2,7 +2,6 @@ from typing import TYPE_CHECKING, Any, cast -import numpy as np from pymmcore_plus.model import ConfigPreset, Setting from qtpy.QtCore import ( QAbstractTableModel, @@ -32,8 +31,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._gidx: QModelIndex | None = None self._presets: list[ConfigPreset] = [] self._rows: list[tuple[str, str]] = [] # (device_name, property_name) - # NDArray[Setting | None] for quick index-based lookup - self._data: np.ndarray = np.empty((0, 0), dtype=object) + self._data: dict[tuple[int, int], Setting] = {} self._root = _Node("", None) def sourceModel(self) -> QConfigGroupsModel | None: @@ -64,6 +62,48 @@ def setGroup(self, group_name_or_index: str | QModelIndex) -> None: 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 + + # 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 for this cell + self.dataChanged.emit(index, index, [role]) + return True + # ---------------------------------------------------------------- build -- def _rebuild(self) -> None: # slot signature is flexible @@ -76,16 +116,13 @@ def _rebuild(self) -> None: # slot signature is flexible 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 = np.empty((len(self._rows), len(self._presets)), dtype=object) + self._data.clear() for col, preset in enumerate(self._presets): for row, (device, prop) in enumerate(self._rows): - # Find the setting for this device/prop in the preset for s in preset.settings: if (s.device_name, s.property_name) == (device, prop): - self._data[row, col] = s + self._data[(row, col)] = s break - else: - self._data[row, col] = None self.endResetModel() @@ -113,7 +150,8 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A if not index.isValid(): return None - if not isinstance(setting := self._data[index.row(), index.column()], Setting): + setting = self._data.get((index.row(), index.column())) + if setting is None: return None if role == Qt.ItemDataRole.UserRole: diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py b/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py index b17c5834e..84b8b3082 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py @@ -15,14 +15,7 @@ def createEditor( return super().createEditor(parent, option, index) dev, prop, *_ = setting widget = PropertyWidget(dev, prop, parent=parent, connect_core=False) - - # For persistent editors, we connect valueChanged to setModelData directly - # instead of commitData to avoid the "editor doesn't belong to view" error - def on_value_changed() -> None: - if index.isValid() and (model := index.model()): - model.setData(index, widget.value(), Qt.ItemDataRole.EditRole) - - widget.valueChanged.connect(on_value_changed) + widget.valueChanged.connect(lambda: self.commitData.emit(widget)) widget.setAutoFillBackground(True) return widget diff --git a/src/pymmcore_widgets/control/_rois/_vispy.py b/src/pymmcore_widgets/control/_rois/_vispy.py index 4e3fe3533..8d0dbd5ec 100644 --- a/src/pymmcore_widgets/control/_rois/_vispy.py +++ b/src/pymmcore_widgets/control/_rois/_vispy.py @@ -46,13 +46,9 @@ def update_vertices(self, vertices: np.ndarray) -> None: self._handles.set_data(pos=vertices) centers: list[tuple[float, float]] = [] - try: - if (grid := self._roi.create_grid_plan()) is not None: - for p in grid: - centers.append((p.x, p.y)) - except Exception as e: - raise - print(e) + if (grid := self._roi.create_grid_plan()) is not None: + for p in grid: + centers.append((p.x, p.y)) if centers and (fov_size := self._roi.fov_size): edges = [] diff --git a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py index 451c00a8c..46f67b09b 100644 --- a/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py +++ b/src/pymmcore_widgets/control/_stage_explorer/_stage_explorer.py @@ -431,7 +431,6 @@ def _on_frame_ready(self, image: np.ndarray, event: useq.MDAEvent) -> None: def _on_poll_stage_action(self, checked: bool) -> None: """Set the poll stage position property based on the state of the action.""" self._stage_pos_marker.visible = checked - print("Stage position marker visible:", self._stage_pos_marker.visible) self._poll_stage_position = checked if checked: self._timer_id = self.startTimer(20) From 6ed116b7baa52602d19cb6d640ec30e39fe3845c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 29 Jun 2025 20:17:59 -0400 Subject: [PATCH 21/70] more improvements --- src/pymmcore_widgets/_icons.py | 18 +- .../config_presets/_qmodel/_config_views.py | 69 +++-- .../config_presets/_qmodel/_presets_table.py | 253 +++++++++++------- .../_qmodel/_property_setting_delegate.py | 1 + 4 files changed, 227 insertions(+), 114 deletions(-) diff --git a/src/pymmcore_widgets/_icons.py b/src/pymmcore_widgets/_icons.py index 6c79daa27..a5f97f1a5 100644 --- a/src/pymmcore_widgets/_icons.py +++ b/src/pymmcore_widgets/_icons.py @@ -1,6 +1,9 @@ from __future__ import annotations -from pymmcore_plus import DeviceType +from contextlib import suppress + +from pymmcore_plus import CMMCorePlus, DeviceType +from superqt import QIconifyIcon ICONS: dict[DeviceType, str] = { DeviceType.Any: "mdi:devices", @@ -21,3 +24,16 @@ DeviceType.XYStage: "mdi:arrow-all", DeviceType.Serial: "mdi:serial-port", } + + +def get_device_icon( + device_type_or_name: DeviceType | str, color: str = "gray" +) -> QIconifyIcon | None: + if isinstance(device_type_or_name, str): + with suppress(Exception): + device_type = CMMCorePlus.instance().getDeviceType(device_type_or_name) + else: + device_type = device_type_or_name + if icon_string := ICONS.get(device_type): + return QIconifyIcon(icon_string, color=color) + return None diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py index df703b0fc..9309d63f6 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, cast -from pymmcore_plus import DeviceType +from pymmcore_plus import DeviceProperty, DeviceType, Keyword from pymmcore_plus.model import ConfigGroup from qtpy.QtCore import QModelIndex, QSize, Qt, Signal from qtpy.QtWidgets import ( @@ -10,6 +10,7 @@ QHBoxLayout, QLabel, QListView, + QSplitter, QToolBar, QVBoxLayout, QWidget, @@ -190,7 +191,7 @@ def __init__(self, parent: QWidget | None = None) -> None: left = QWidget() lv = QVBoxLayout(left) - lv.setContentsMargins(0, 0, 0, 0) + lv.setContentsMargins(12, 12, 4, 12) lv.addWidget(group_box) lv.addWidget(preset_box) @@ -309,43 +310,61 @@ def _on_model_data_changed( _roles: list[int] | None = None, ) -> None: """Refresh DevicePropertyTable if a setting in the current preset was edited.""" - cur_preset = self._preset_view.currentIndex() - if not cur_preset.isValid(): - return - - # We only care about edits to rows that are direct children of the - # currently-selected preset (i.e. Setting rows). - if topLeft.parent() != cur_preset: + if not (preset := self._our_preset_changed_by_range(topLeft, bottomRight)): return - # pull updated settings from the model and push to the table - node = cast("_Node", cur_preset.internalPointer()) - preset = cast("ConfigPreset", node.payload) self._props.blockSignals(True) # avoid feedback loop self._props.setValue(preset.settings) self._props.blockSignals(False) + def _our_preset_changed_by_range( + self, topLeft: QModelIndex, bottomRight: QModelIndex + ) -> ConfigPreset | None: + """Return our current preset if it was changed in the given range.""" + cur_preset = self._preset_view.currentIndex() + if ( + not cur_preset.isValid() + or not topLeft.isValid() + or topLeft.parent() != cur_preset.parent() + or topLeft.internalPointer().payload.name + != cur_preset.internalPointer().payload.name + ): + return None + + # pull updated settings from the model and push to the table + node = cast("_Node", self._preset_view.currentIndex().internalPointer()) + preset = cast("ConfigPreset", node.payload) + return preset + -class _PropSettings(QWidget): +class _PropSettings(QSplitter): """A wrapper for DevicePropertyTable for use in ConfigGroupsEditor.""" valueChanged = Signal() def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) + super().__init__(Qt.Orientation.Vertical, parent) + # 2D table with presets as columns and device properties as rows self._presets_table = PresetsTable(self) - self._action_group = QActionGroup(self) - self._action_group.setExclusive(False) + # regular property table for editing all device properties self._prop_tables = DevicePropertyTable() + self._prop_tables.valueChanged.connect(self.valueChanged) self._prop_tables.setRowsCheckable(True) + # toolbar with device type buttons + self._action_group = QActionGroup(self) + self._action_group.setExclusive(False) tb, self._action_group = self._create_device_buttons() - rv = QVBoxLayout(self) - rv.setContentsMargins(0, 0, 0, 0) - rv.addWidget(self._presets_table) - rv.addWidget(tb) - rv.addWidget(self._prop_tables) + + bot = QWidget() + bl = QVBoxLayout(bot) + bl.setContentsMargins(0, 0, 0, 0) + bl.addWidget(tb) + bl.addWidget(self._prop_tables) + + self.addWidget(self._presets_table) + self.addWidget(bot) self._filter_properties() @@ -417,6 +436,7 @@ def _filter_properties(self) -> None: include_read_only=False, always_show_checked=True, include_devices=include_devices, + predicate=_hide_state_state, ) def _update_device_buttons(self, core: CMMCorePlus) -> None: @@ -435,3 +455,10 @@ def _update_device_buttons(self, core: CMMCorePlus) -> None: break else: action.setVisible(False) + + +def _hide_state_state(prop: DeviceProperty) -> bool | None: + """Hide the State property for StateDevice (it duplicates state label).""" + if prop.deviceType() == DeviceType.StateDevice and prop.name == Keyword.State: + return False + return None diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py b/src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py index b7d4b3cba..f1c1a24a2 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py @@ -1,5 +1,6 @@ from __future__ import annotations +from contextlib import suppress from typing import TYPE_CHECKING, Any, cast from pymmcore_plus.model import ConfigPreset, Setting @@ -13,6 +14,8 @@ from qtpy.QtWidgets import QTableView, QToolBar, QVBoxLayout, QWidget from superqt import QIconifyIcon +from pymmcore_widgets._icons import get_device_icon + from ._config_model import QConfigGroupsModel, _Node from ._property_setting_delegate import PropertySettingDelegate @@ -21,7 +24,141 @@ from pymmcore_plus.model import ConfigPreset +class PresetsTable(QWidget): + """A 2D table View onto a ConfigGroup's presets. + + With all the presets as columns and the device/property pairs as rows. + (unless transposed). + + To use, call `setModel` with a `QConfigGroupsModel`, and then + `setGroup` with the name or index of the group you want to view. + """ + + @classmethod + def create_from_core( + cls, core: CMMCorePlus, parent: QWidget | None = None + ) -> PresetsTable: + """Create a PresetsTable from a CMMCorePlus instance.""" + obj = cls(parent) + model = QConfigGroupsModel.create_from_core(core) + obj.setModel(model) + return obj + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.table_view = QTableView(self) + self.table_view.setItemDelegate(PropertySettingDelegate(self.table_view)) + + self._toolbar = tb = QToolBar(self) + tb.setIconSize(QSize(16, 16)) + tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + if act := tb.addAction( + QIconifyIcon("carbon:transpose"), "Transpose", self._transpose + ): + act.setCheckable(True) + + tb.addAction("Remove", self._on_remove_action) + tb.addAction("Duplicate", self._on_duplicate_action) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._toolbar) + layout.addWidget(self.table_view) + + def _get_selected_preset_index(self) -> QModelIndex: + """Get the currently selected preset from the source model.""" + if sm := self.table_view.selectionModel(): + if indices := sm.selectedColumns(): + pivot_model = self._get_pivot_model() + col = indices[0].column() + return pivot_model.get_source_index_for_column(col) + return QModelIndex() + + def _on_remove_action(self) -> None: + if self._is_transposed(): + ... + else: + source_idx = self._get_selected_preset_index() + self._get_source_model().remove(source_idx) + + def _on_duplicate_action(self) -> None: + if self._is_transposed(): + ... + else: + source_idx = self._get_selected_preset_index() + self._get_source_model().duplicate_preset(source_idx) + + def _is_transposed(self) -> bool: + """Check if the table view is currently transposed.""" + return isinstance(self.table_view.model(), QTransposeProxyModel) + + def _transpose(self) -> None: + """Transpose the table view.""" + pivot = self.table_view.model() + if isinstance(pivot, _ConfigGroupPivotModel): + proxy = QTransposeProxyModel(self) + proxy.setSourceModel(pivot) + self.table_view.setModel(proxy) + elif isinstance(pivot, QTransposeProxyModel): + # Already transposed, revert to original model + source_model = pivot.sourceModel() + if isinstance(source_model, _ConfigGroupPivotModel): + self.table_view.setModel(source_model) + + def sourceModel(self) -> QConfigGroupsModel | None: + """Return the source model of the table view.""" + with suppress(ValueError): + return self._get_pivot_model().sourceModel() + return None + + def setModel(self, model: QConfigGroupsModel | _ConfigGroupPivotModel) -> None: + """Set the model for the table view.""" + if isinstance(model, QConfigGroupsModel): + matrix = _ConfigGroupPivotModel() + matrix.setSourceModel(model) + elif isinstance(model, _ConfigGroupPivotModel): + matrix = model + else: + raise TypeError( + "Model must be an instance of QConfigGroupsModel " + "or ConfigGroupPivotModel." + ) + + self.table_view.setModel(matrix) + matrix.modelReset.connect(self._on_model_reset) + + def _on_model_reset(self) -> None: + matrix = self.table_view.model() + if not isinstance(matrix, _ConfigGroupPivotModel): + return + + if hh := self.table_view.horizontalHeader(): + hh.setSectionResizeMode(hh.ResizeMode.Stretch) + + 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) + + def _get_pivot_model(self) -> _ConfigGroupPivotModel: + model = self.table_view.model() + if isinstance(model, QTransposeProxyModel): + model = cast("_ConfigGroupPivotModel", model.sourceModel()) + if not isinstance(model, _ConfigGroupPivotModel): + raise ValueError("Source model is not set. Call setSourceModel first.") + return model + + def _get_source_model(self) -> QConfigGroupsModel: + pivot_model = self._get_pivot_model() + src_model = pivot_model.sourceModel() + if not isinstance(src_model, QConfigGroupsModel): + raise ValueError("Source model is not a QConfigGroupsModel.") + return src_model + + # ----------------------------------------------------------------------------- + + class _ConfigGroupPivotModel(QAbstractTableModel): """Pivot a single ConfigGroup into rows=Device/Property, cols=Presets.""" @@ -100,8 +237,8 @@ def setData( if preset_idx.isValid(): self._src.update_preset_settings(preset_idx, preset_settings) - # Emit dataChanged for this cell - self.dataChanged.emit(index, index, [role]) + # Emit dataChanged signal for the specific cell + self._src.dataChanged.emit(preset_idx, preset_idx, [role]) return True # ---------------------------------------------------------------- build -- @@ -140,11 +277,19 @@ def headerData( orient: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole, ) -> Any: - if role != Qt.ItemDataRole.DisplayRole: - return None - if orient == Qt.Orientation.Horizontal: - return self._presets[section].name - return "-".join(self._rows[section]) + 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: + 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(): @@ -174,89 +319,13 @@ def flags(self, index: QModelIndex) -> Qt.ItemFlag: | 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: + raise ValueError("Source model or group index is not set.") + if column < 0 or column >= len(self._presets): + raise IndexError("Column index out of range.") -class PresetsTable(QWidget): - """A simple table widget to display presets.""" - - @classmethod - def create_from_core( - cls, core: CMMCorePlus, parent: QWidget | None = None - ) -> PresetsTable: - """Create a PresetsTable from a CMMCorePlus instance.""" - obj = cls(parent) - model = QConfigGroupsModel.create_from_core(core) - obj.setModel(model) - return obj - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.table_view = QTableView(self) - self.table_view.setItemDelegate(PropertySettingDelegate(self.table_view)) - - self._toolbar = tb = QToolBar(self) - tb.setIconSize(QSize(16, 16)) - tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) - if act := tb.addAction( - QIconifyIcon("carbon:transpose"), "Transpose", self._transpose - ): - act.setCheckable(True) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._toolbar) - layout.addWidget(self.table_view) - - def _update_view(self) -> None: - matrix = self.table_view.model() - if not isinstance(matrix, _ConfigGroupPivotModel): - return - - if hh := self.table_view.horizontalHeader(): - hh.setSectionResizeMode(hh.ResizeMode.Stretch) - - def _transpose(self) -> None: - """Transpose the table view.""" - pivot = self.table_view.model() - if isinstance(pivot, _ConfigGroupPivotModel): - proxy = QTransposeProxyModel(self) - proxy.setSourceModel(pivot) - self.table_view.setModel(proxy) - elif isinstance(pivot, QTransposeProxyModel): - # Already transposed, revert to original model - source_model = pivot.sourceModel() - if isinstance(source_model, _ConfigGroupPivotModel): - self.table_view.setModel(source_model) - - def sourceModel(self) -> QConfigGroupsModel | None: - """Return the source model of the table view.""" - model = self.table_view.model() - if isinstance(model, QTransposeProxyModel): - model = cast("_ConfigGroupPivotModel", model.sourceModel()) - if isinstance(model, _ConfigGroupPivotModel): - return model.sourceModel() - return None - - def setModel(self, model: QConfigGroupsModel | _ConfigGroupPivotModel) -> None: - """Set the model for the table view.""" - if isinstance(model, QConfigGroupsModel): - matrix = _ConfigGroupPivotModel() - matrix.setSourceModel(model) - elif isinstance(model, _ConfigGroupPivotModel): - matrix = model - else: - raise TypeError( - "Model must be an instance of QConfigGroupsModel " - "or ConfigGroupPivotModel." - ) - - self.table_view.setModel(matrix) - matrix.modelReset.connect(self._update_view) - - def setGroup(self, group_name_or_index: str | QModelIndex) -> None: - """Set the group for the pivot model.""" - model = self.table_view.model() - if isinstance(model, QTransposeProxyModel): - model = cast("_ConfigGroupPivotModel", model.sourceModel()) - if not isinstance(model, _ConfigGroupPivotModel): - raise ValueError("Source model is not set. Call setSourceModel first.") - model.setGroup(group_name_or_index) + 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/_qmodel/_property_setting_delegate.py b/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py index 84b8b3082..19958698a 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py @@ -15,6 +15,7 @@ def createEditor( return super().createEditor(parent, option, index) dev, prop, *_ = setting widget = PropertyWidget(dev, prop, parent=parent, connect_core=False) + widget.setValue(setting.property_value) widget.valueChanged.connect(lambda: self.commitData.emit(widget)) widget.setAutoFillBackground(True) return widget From ae6940da32382ca763c1cc65632e314b4b354188 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 29 Jun 2025 20:18:30 -0400 Subject: [PATCH 22/70] more improvements --- .../config_presets/_qmodel/_property_setting_delegate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py b/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py index 19958698a..d97f2883f 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py @@ -15,7 +15,7 @@ def createEditor( return super().createEditor(parent, option, index) dev, prop, *_ = setting widget = PropertyWidget(dev, prop, parent=parent, connect_core=False) - widget.setValue(setting.property_value) + widget.setValue(setting.property_value) # avoids commitData warnings widget.valueChanged.connect(lambda: self.commitData.emit(widget)) widget.setAutoFillBackground(True) return widget From 0f71c1640fea3bf92c608e50bd00e28eaa272d7e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 30 Jun 2025 09:21:28 -0400 Subject: [PATCH 23/70] starting tests --- .../config_presets/_qmodel/_config_model.py | 53 ++++++++++++------- tests/test_config_groups_model.py | 36 +++++++++++++ 2 files changed, 70 insertions(+), 19 deletions(-) create mode 100644 tests/test_config_groups_model.py diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py index d0072c899..b3cb8fea1 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py +++ b/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py @@ -21,10 +21,12 @@ from pymmcore_widgets.device_properties._property_widget import PropertyWidget if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Mapping from typing_extensions import Self +NULL_INDEX = QModelIndex() + class Col(IntEnum): """Column indices for the ConfigTreeModel.""" @@ -184,11 +186,25 @@ def remove(self, idx: QModelIndex) -> None: # structure helpers ------------------------------------------------------- def _node(self, index: QModelIndex | None) -> _Node: - return ( - cast("_Node", index.internalPointer()) - if index and index.isValid() - else self._root - ) + 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 + + def python_object( + self, index: QModelIndex = NULL_INDEX + ) -> Mapping[str, ConfigGroup] | ConfigGroup | ConfigPreset | Setting | None: + """Return the Python object (ConfigGroup, ConfigPreset, or Setting) at index.""" + node = self._node(index) + if node is self._root: + # return a copy of the root's children as a dict + return {n.name: cast("ConfigGroup", n.payload) for n in self._root.children} + return node.payload def rowCount(self, parent: QModelIndex | None = None) -> int: return len(self._node(parent).children) @@ -209,20 +225,16 @@ def parent(self, child: QModelIndex) -> QModelIndex: If the item has no parent, an invalid QModelIndex is returned. """ - if not child or not child.isValid(): - return QModelIndex() - parent_node = cast("_Node", child.internalPointer()).parent - if parent_node is self._root or parent_node is None: + node = self._node(child) + if node is self._root or not (parent_node := node.parent): return QModelIndex() return self.createIndex(parent_node.row_in_parent(), 0, parent_node) # data & editing ---------------------------------------------------------- def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: - if not index.isValid(): - return None - - if not isinstance(node := index.internalPointer(), _Node): + node = self._node(index) + if node is self._root: return None if role == Qt.ItemDataRole.UserRole: @@ -267,10 +279,11 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A return None def flags(self, index: QModelIndex) -> Qt.ItemFlag: - if not index.isValid(): + node = self._node(index) + if node is self._root: return Qt.ItemFlag.NoItemFlags + fl = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled - node = cast("_Node", index.internalPointer()) if node.is_setting and index.column() == Col.Value: fl |= Qt.ItemFlag.ItemIsEditable elif not node.is_setting and index.column() == Col.Item: @@ -283,9 +296,10 @@ def setData( value: Any, role: int = Qt.ItemDataRole.EditRole, ) -> bool: - if role != Qt.ItemDataRole.EditRole or not index.isValid(): + node = self._node(index) + if node is self._root or role != Qt.ItemDataRole.EditRole: return False - node = cast("_Node", index.internalPointer()) + if node.is_setting and index.column() == Col.Value: setting = cast("Setting", node.payload) setting = Setting( @@ -304,7 +318,8 @@ def setData( break self.dataChanged.emit(index, index, [role]) return True - new_name = cast("str", value) + + new_name = str(value) if new_name == node.name: return True if not new_name: diff --git a/tests/test_config_groups_model.py b/tests/test_config_groups_model.py new file mode 100644 index 000000000..c0fe9202d --- /dev/null +++ b/tests/test_config_groups_model.py @@ -0,0 +1,36 @@ +import pytest +from pymmcore_plus import CMMCorePlus +from pymmcore_plus.model import ConfigGroup + +from pymmcore_widgets.config_presets._qmodel._config_model import QConfigGroupsModel + + +@pytest.fixture +def model() -> QConfigGroupsModel: + """Fixture to create a QConfigGroupsModel instance.""" + core = CMMCorePlus() + core.loadSystemConfiguration() + model = QConfigGroupsModel.create_from_core(core) + return model + + +def test_model_initialization() -> None: + """Test the initialization of the QConfigGroupsModel.""" + # not using the fixture here, as we want to test the model creation directly + core = CMMCorePlus() + core.loadSystemConfiguration() + python_info = ConfigGroup.all_config_groups(core) + model = QConfigGroupsModel(python_info.values()) + + assert isinstance(model, QConfigGroupsModel) + assert model.rowCount() > 0 + assert model.columnCount() == 3 + + # original data is recovered intact + assert model.python_object() == python_info + + # we can also index deeper + for row, value in enumerate(python_info.values()): + assert model.python_object(model.index(row)) == value + + assert not model.parent(model.index(3)).isValid() From 443deb2572d27b6992c69ceadfd9e7d010b85e8d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 30 Jun 2025 17:30:27 -0400 Subject: [PATCH 24/70] remove config props --- .../_qmodel/_config_properties.py | 377 ------------------ 1 file changed, 377 deletions(-) delete mode 100644 src/pymmcore_widgets/config_presets/_qmodel/_config_properties.py diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_properties.py b/src/pymmcore_widgets/config_presets/_qmodel/_config_properties.py deleted file mode 100644 index 1ad23e0e9..000000000 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_properties.py +++ /dev/null @@ -1,377 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, ClassVar, cast - -from pymmcore_plus import CMMCorePlus, DeviceProperty, DeviceType, Keyword -from pymmcore_plus.model import Setting -from qtpy.QtCore import QEvent, Qt, Signal -from qtpy.QtWidgets import ( - QCheckBox, - QComboBox, - QGroupBox, - QHBoxLayout, - QLabel, - QSpacerItem, - QSplitter, - QVBoxLayout, - QWidget, -) - -from pymmcore_widgets.device_properties import DevicePropertyTable - -if TYPE_CHECKING: - from collections.abc import Iterable, Mapping - - from qtpy.QtGui import QMouseEvent - - -class GroupedDevicePropertyTable(QWidget): - """More opinionated arrangement of device properties. - - This widget contains multiple `DevicePropertyTable` widgets, each filtered - to highlight different aspects of the device properties. - - It's API mimics that of `DevicePropertyTable`, allowing you to get and set - the union of checked settings across all sub-tables... it should be a drop-in - replacement for `DevicePropertyTable` in most cases. (assuming you limit your API - to value(), setValue(), and valueChanged()). - """ - - valueChanged = Signal() - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - - # Groups ----------------------------------------------------- - self.light_path_props = _LightPathGroupBox(self) - self.camera_props = _CameraGroupBox(self) - - # layout ------------------------------------------------------------ - - splitter = QSplitter(Qt.Orientation.Vertical, self) - splitter.setContentsMargins(0, 0, 0, 0) - splitter.addWidget(self.light_path_props) - splitter.addWidget(self.camera_props) - splitter.setStretchFactor(0, 3) - splitter.setStretchFactor(1, 1) - splitter.setStretchFactor(2, 0) - - lay = QVBoxLayout(self) - lay.setContentsMargins(0, 0, 0, 0) - lay.addWidget(splitter) - - # init ------------------------------------------------------------ - - self.light_path_props.valueChanged.connect(self.valueChanged) - self.camera_props.valueChanged.connect(self.valueChanged) - - # --------------------------------------------------------------------- API - - def value(self) -> list[Setting]: - """Return the union of checked settings from both panels.""" - # remove duplicates by converting to a dict keyed on (device, prop_name) - settings = { - (setting[0], setting[1]): setting - for group in (self.light_path_props, self.camera_props) - for setting in group.value() - } - return list(settings.values()) - - def setValue(self, value: Iterable[Setting]) -> None: - self.light_path_props.setValue(value) - self.camera_props.setValue(value) - - def update_options_from_core(self, core: CMMCorePlus) -> None: - """Populate the comboboxes with the available devices from the core.""" - self.light_path_props.active_shutter.update_from_core(core) - self.camera_props.active_camera.update_from_core(core) - - -def _is_not_objective(prop: DeviceProperty) -> bool: - return not any(x in prop.device for x in prop.core.guessObjectiveDevices()) - - -def _light_path_predicate(prop: DeviceProperty) -> bool | None: - devtype = prop.deviceType() - if devtype in ( - DeviceType.Camera, - DeviceType.Core, - DeviceType.AutoFocus, - DeviceType.Stage, - DeviceType.XYStage, - ): - return False - if devtype == DeviceType.State: - if "State" in prop.name or "ClosedPosition" in prop.name: - return False - if devtype == DeviceType.Shutter and prop.name == Keyword.State.value: - return False - if not _is_not_objective(prop): - return False - return None - - -class _LightPathGroupBox(QGroupBox): - valueChanged = Signal() - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__("Light Path", parent) - # self.setCheckable(True) - self.toggled.connect(self.valueChanged) - - self.active_shutter = CoreRoleSelector(DeviceType.ShutterDevice, self) - self.active_shutter.valueChanged.connect(self.valueChanged) - - self.show_all = QCheckBox("Show All Properties", self) - self.show_all.toggled.connect(self._show_all_toggled) - - self.props = DevicePropertyTable(self, connect_core=False) - self.props.valueChanged.connect(self.valueChanged) - self.props.setRowsCheckable(True) - self.props.filterDevices( - include_read_only=False, - include_pre_init=False, - predicate=_light_path_predicate, - ) - - shutter_layout = QHBoxLayout() - shutter_layout.setContentsMargins(2, 0, 0, 0) - shutter_layout.addWidget(self.active_shutter, 1) - shutter_layout.addSpacerItem(QSpacerItem(40, 0)) - shutter_layout.addWidget(self.show_all, 0) - - layout = QVBoxLayout(self) - layout.setContentsMargins(8, 8, 8, 8) - layout.setSpacing(0) - layout.addLayout(shutter_layout) - layout.addWidget(self.props) - - def _show_all_toggled(self, show_all: bool) -> None: - self.props.filterDevices( - exclude_devices=(DeviceType.Camera, DeviceType.Core), - include_read_only=False, - include_pre_init=False, - always_show_checked=True, - predicate=_light_path_predicate if not show_all else _is_not_objective, - ) - - def value(self) -> Iterable[Setting]: - yield from self.props.getCheckedProperties(visible_only=True) - if self.active_shutter.isChecked(): - yield Setting( - Keyword.CoreDevice.value, - Keyword.CoreShutter.value, - self.active_shutter.currentText(), - ) - - def setValue(self, value: Iterable[tuple[str, str, str]]) -> None: - """Set the value of the properties in this group.""" - self.props.setValue(value) - self.active_shutter.setChecked(False) - for device, prop, val in value: - if device == Keyword.CoreDevice.value and prop == Keyword.CoreShutter.value: - self.active_shutter.setCurrentText(val) - self.active_shutter.setChecked(True) - break - - -class _CameraGroupBox(QGroupBox): - valueChanged = Signal() - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__("Camera", parent) - self.setCheckable(True) - self.setChecked(True) - self.toggled.connect(self.valueChanged) - - self.props = DevicePropertyTable(self, connect_core=False) - self.props.valueChanged.connect(self.valueChanged) - self.props.setRowsCheckable(True) - self.props.filterDevices( - include_devices=[DeviceType.Camera], - include_read_only=False, - ) - - self.active_camera = CoreRoleSelector(DeviceType.CameraDevice, self) - self.active_camera.valueChanged.connect(self.valueChanged) - - layout = QVBoxLayout(self) - layout.setContentsMargins(8, 8, 8, 8) - layout.setSpacing(0) - layout.addWidget(self.active_camera) - layout.addWidget(self.props) - - def value(self) -> Iterable[Setting]: - if not self.isChecked(): - return - yield from self.props.getCheckedProperties(visible_only=True) - if self.active_camera.isChecked(): - yield Setting( - Keyword.CoreDevice.value, - Keyword.CoreCamera.value, - self.active_camera.currentText(), - ) - - def setValue(self, value: Iterable[tuple[str, str, str]]) -> None: - """Set the value of the properties in this group.""" - self.props.setValue(value) - - self.active_camera.setChecked(False) - for device, prop, val in value: - if device == Keyword.CoreDevice.value and prop == Keyword.CoreCamera.value: - self.active_camera.setCurrentText(val) - self.active_camera.setChecked(True) - break - - if ( - self.props.getCheckedProperties(visible_only=True) - or self.active_camera.isChecked() - ): - self.setChecked(True) - - -class _ClickableLabel(QLabel): - """A QLabel that emits a signal when clicked (even when disabled).""" - - clicked = Signal() - - def event(self, event: QEvent | None) -> bool: - """Override event to handle mouse press even when disabled.""" - if event and event.type() == (QEvent.Type.MouseButtonRelease): - if cast("QMouseEvent", event).button() == Qt.MouseButton.LeftButton: - self.clicked.emit() - return True - return super().event(event) # type: ignore[no-any-return] - - -class CheckableComboBox(QWidget): - """Row containing checkbox, label, and combobox. - - Useful for settings that can be enabled/disabled with a checkbox. - (Rather than adding a "null" option to the combobox.) - """ - - valueChanged = Signal() - - def __init__( - self, - label: str | None = None, - parent: QWidget | None = None, - *, - checkable: bool = True, - ) -> None: - super().__init__(parent) - - self.checkbox = QCheckBox(self) # not using label so we can independent enable - self.label = _ClickableLabel(label or "", self) - self.combobox = QComboBox(self) - - self.combobox.currentTextChanged.connect(self.valueChanged) - self.checkbox.toggled.connect(self.valueChanged) - - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.checkbox) - layout.addWidget(self.label) - layout.addWidget(self.combobox, 1) - - if checkable: - self.label.clicked.connect(self.checkbox.toggle) - self.label.setEnabled(False) - self.combobox.setEnabled(False) - else: - self.checkbox.setEnabled(False) - self.checkbox.setVisible(False) - - self.checkbox.checkStateChanged.connect(self._on_checkbox_changed) - - def clear(self) -> None: - """Clear the combobox.""" - self.combobox.clear() - - def addItem(self, item: str) -> None: - """Add item to the combobox.""" - self.combobox.addItem(item) - - def addItems(self, items: Iterable[str]) -> None: - """Add items to the combobox.""" - self.combobox.addItems(items) - - def isChecked(self) -> bool: - """Return whether the checkbox is checked.""" - return self.checkbox.isChecked() # type: ignore[no-any-return] - - def setChecked(self, checked: bool) -> None: - """Set the checkbox state.""" - self.checkbox.setChecked(checked) - - def currentText(self) -> str: - """Return the current text of the combobox.""" - return self.combobox.currentText() # type: ignore[no-any-return] - - def setCurrentText(self, text: str) -> None: - """Set the current text of the combobox.""" - self.combobox.setCurrentText(text) - - def _on_checkbox_changed(self, state: Qt.CheckState) -> None: - """Handle checkbox state change.""" - checked = state == Qt.CheckState.Checked - self.combobox.setEnabled(checked) - self.label.setEnabled(checked) - - -class CoreRoleSelector(CheckableComboBox): - """Widget for selecting a core role.""" - - METHOD_MAP: ClassVar[Mapping[DeviceType, str]] = { - DeviceType.Camera: "getCameraDevice", - DeviceType.Shutter: "getShutterDevice", - DeviceType.Stage: "getFocusDevice", - DeviceType.XYStage: "getXYStageDevice", - DeviceType.AutoFocus: "getAutoFocusDevice", - DeviceType.ImageProcessor: "getImageProcessorDevice", - DeviceType.SLM: "getSLMDevice", - DeviceType.Galvo: "getGalvoDevice", - } - - def __init__( - self, - device_type: DeviceType, - parent: QWidget | None = None, - *, - label: str | None = None, - ) -> None: - if device_type not in CoreRoleSelector.METHOD_MAP: - raise ValueError(f"MMCore has no 'current' {device_type.name} ") - - self.device_type = device_type - if label is None: - label = f"Active {device_type.name.replace('Device', '')}:" - super().__init__(label, parent, checkable=True) - - def update_from_core( - self, - core: CMMCorePlus | None = None, - *, - update_options: bool = True, - update_current: bool = True, - ) -> None: - """Update the combobox with the current core settings. - - If `update_options` is True, it will refresh the list of devices. - If `update_current` is True, it will set the current text to the active device. - """ - core = core or CMMCorePlus.instance() - - if update_options: - self.clear() - devices = core.getLoadedDevicesOfType(self.device_type) - self.addItems(["", *devices]) - - if update_current: - method_name = self.METHOD_MAP[self.device_type] - method = getattr(core, method_name) - try: - self.setCurrentText(method()) - except Exception: - self.setCurrentText("") From 65088a73c24115830e302377b62407ee4a515064 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 30 Jun 2025 21:38:04 -0400 Subject: [PATCH 25/70] move file --- src/pymmcore_widgets/__init__.py | 2 +- .../config_presets/__init__.py | 2 + .../config_presets/_qmodel/_presets_table.py | 331 ------------------ .../{_qmodel => _views}/_config_views.py | 9 +- 4 files changed, 9 insertions(+), 335 deletions(-) delete mode 100644 src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py rename src/pymmcore_widgets/config_presets/{_qmodel => _views}/_config_views.py (98%) diff --git a/src/pymmcore_widgets/__init__.py b/src/pymmcore_widgets/__init__.py index 1ee86b49e..fbf6a83e3 100644 --- a/src/pymmcore_widgets/__init__.py +++ b/src/pymmcore_widgets/__init__.py @@ -50,12 +50,12 @@ from ._install_widget import InstallWidget from ._log import CoreLogWidget from .config_presets import ( + ConfigGroupsEditor, ConfigGroupsTree, GroupPresetTableWidget, ObjectivesPixelConfigurationWidget, PixelConfigurationWidget, ) -from .config_presets._qmodel._config_views import ConfigGroupsEditor from .control import ( CameraRoiWidget, ChannelGroupWidget, diff --git a/src/pymmcore_widgets/config_presets/__init__.py b/src/pymmcore_widgets/config_presets/__init__.py index a1e1eaf63..ddfeb3077 100644 --- a/src/pymmcore_widgets/config_presets/__init__.py +++ b/src/pymmcore_widgets/config_presets/__init__.py @@ -6,8 +6,10 @@ from ._qmodel._config_model import QConfigGroupsModel from ._views._config_groups_tree import ConfigGroupsTree from ._views._config_presets_table import ConfigPresetsTable +from ._views._config_views import ConfigGroupsEditor __all__ = [ + "ConfigGroupsEditor", "ConfigGroupsTree", "ConfigPresetsTable", "GroupPresetTableWidget", diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py b/src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py deleted file mode 100644 index f1c1a24a2..000000000 --- a/src/pymmcore_widgets/config_presets/_qmodel/_presets_table.py +++ /dev/null @@ -1,331 +0,0 @@ -from __future__ import annotations - -from contextlib import suppress -from typing import TYPE_CHECKING, Any, cast - -from pymmcore_plus.model import ConfigPreset, Setting -from qtpy.QtCore import ( - QAbstractTableModel, - QModelIndex, - QSize, - Qt, - QTransposeProxyModel, -) -from qtpy.QtWidgets import QTableView, QToolBar, QVBoxLayout, QWidget -from superqt import QIconifyIcon - -from pymmcore_widgets._icons import get_device_icon - -from ._config_model import QConfigGroupsModel, _Node -from ._property_setting_delegate import PropertySettingDelegate - -if TYPE_CHECKING: - from pymmcore_plus import CMMCorePlus - from pymmcore_plus.model import ConfigPreset - - -class PresetsTable(QWidget): - """A 2D table View onto a ConfigGroup's presets. - - With all the presets as columns and the device/property pairs as rows. - (unless transposed). - - To use, call `setModel` with a `QConfigGroupsModel`, and then - `setGroup` with the name or index of the group you want to view. - """ - - @classmethod - def create_from_core( - cls, core: CMMCorePlus, parent: QWidget | None = None - ) -> PresetsTable: - """Create a PresetsTable from a CMMCorePlus instance.""" - obj = cls(parent) - model = QConfigGroupsModel.create_from_core(core) - obj.setModel(model) - return obj - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.table_view = QTableView(self) - self.table_view.setItemDelegate(PropertySettingDelegate(self.table_view)) - - self._toolbar = tb = QToolBar(self) - tb.setIconSize(QSize(16, 16)) - tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) - if act := tb.addAction( - QIconifyIcon("carbon:transpose"), "Transpose", self._transpose - ): - act.setCheckable(True) - - tb.addAction("Remove", self._on_remove_action) - tb.addAction("Duplicate", self._on_duplicate_action) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._toolbar) - layout.addWidget(self.table_view) - - def _get_selected_preset_index(self) -> QModelIndex: - """Get the currently selected preset from the source model.""" - if sm := self.table_view.selectionModel(): - if indices := sm.selectedColumns(): - pivot_model = self._get_pivot_model() - col = indices[0].column() - return pivot_model.get_source_index_for_column(col) - return QModelIndex() - - def _on_remove_action(self) -> None: - if self._is_transposed(): - ... - else: - source_idx = self._get_selected_preset_index() - self._get_source_model().remove(source_idx) - - def _on_duplicate_action(self) -> None: - if self._is_transposed(): - ... - else: - source_idx = self._get_selected_preset_index() - self._get_source_model().duplicate_preset(source_idx) - - def _is_transposed(self) -> bool: - """Check if the table view is currently transposed.""" - return isinstance(self.table_view.model(), QTransposeProxyModel) - - def _transpose(self) -> None: - """Transpose the table view.""" - pivot = self.table_view.model() - if isinstance(pivot, _ConfigGroupPivotModel): - proxy = QTransposeProxyModel(self) - proxy.setSourceModel(pivot) - self.table_view.setModel(proxy) - elif isinstance(pivot, QTransposeProxyModel): - # Already transposed, revert to original model - source_model = pivot.sourceModel() - if isinstance(source_model, _ConfigGroupPivotModel): - self.table_view.setModel(source_model) - - def sourceModel(self) -> QConfigGroupsModel | None: - """Return the source model of the table view.""" - with suppress(ValueError): - return self._get_pivot_model().sourceModel() - return None - - def setModel(self, model: QConfigGroupsModel | _ConfigGroupPivotModel) -> None: - """Set the model for the table view.""" - if isinstance(model, QConfigGroupsModel): - matrix = _ConfigGroupPivotModel() - matrix.setSourceModel(model) - elif isinstance(model, _ConfigGroupPivotModel): - matrix = model - else: - raise TypeError( - "Model must be an instance of QConfigGroupsModel " - "or ConfigGroupPivotModel." - ) - - self.table_view.setModel(matrix) - matrix.modelReset.connect(self._on_model_reset) - - def _on_model_reset(self) -> None: - matrix = self.table_view.model() - if not isinstance(matrix, _ConfigGroupPivotModel): - return - - if hh := self.table_view.horizontalHeader(): - hh.setSectionResizeMode(hh.ResizeMode.Stretch) - - 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) - - def _get_pivot_model(self) -> _ConfigGroupPivotModel: - model = self.table_view.model() - if isinstance(model, QTransposeProxyModel): - model = cast("_ConfigGroupPivotModel", model.sourceModel()) - if not isinstance(model, _ConfigGroupPivotModel): - raise ValueError("Source model is not set. Call setSourceModel first.") - return model - - def _get_source_model(self) -> QConfigGroupsModel: - pivot_model = self._get_pivot_model() - src_model = pivot_model.sourceModel() - if not isinstance(src_model, QConfigGroupsModel): - raise ValueError("Source model is not a QConfigGroupsModel.") - return src_model - - -# ----------------------------------------------------------------------------- - - -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] = {} - self._root = _Node("", None) - - 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): - 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: - 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(): - 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 - - # 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 - 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: - return len(self._rows) - - def columnCount(self, parent: QModelIndex | None = None) -> int: - 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: - 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(): - 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(): - 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: - raise ValueError("Source model or group index is not set.") - if column < 0 or column >= len(self._presets): - 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/_qmodel/_config_views.py b/src/pymmcore_widgets/config_presets/_views/_config_views.py similarity index 98% rename from src/pymmcore_widgets/config_presets/_qmodel/_config_views.py rename to src/pymmcore_widgets/config_presets/_views/_config_views.py index e9a684809..ef75990be 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_views.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_views.py @@ -18,10 +18,13 @@ from superqt import QIconifyIcon from pymmcore_widgets._icons import ICONS -from pymmcore_widgets.config_presets._qmodel._presets_table import PresetsTable +from pymmcore_widgets.config_presets._qmodel._config_model import ( + QConfigGroupsModel, + _Node, +) from pymmcore_widgets.device_properties import DevicePropertyTable -from ._config_model import QConfigGroupsModel, _Node +from ._config_presets_table import ConfigPresetsTable if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -345,7 +348,7 @@ class _PropSettings(QSplitter): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(Qt.Orientation.Vertical, parent) # 2D table with presets as columns and device properties as rows - self._presets_table = PresetsTable(self) + self._presets_table = ConfigPresetsTable(self) # regular property table for editing all device properties self._prop_tables = DevicePropertyTable() From b951b88b4f77b7eb02150c4b7da14ecc116cbe4a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 30 Jun 2025 21:41:20 -0400 Subject: [PATCH 26/70] remove file --- examples/config_groups_editor.py | 12 ++---- .../_qmodel/_property_setting_delegate.py | 40 ------------------- 2 files changed, 3 insertions(+), 49 deletions(-) delete mode 100644 src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py diff --git a/examples/config_groups_editor.py b/examples/config_groups_editor.py index d04daf8e5..bb29d68e8 100644 --- a/examples/config_groups_editor.py +++ b/examples/config_groups_editor.py @@ -1,11 +1,8 @@ from pymmcore_plus import CMMCorePlus from qtpy.QtCore import QModelIndex -from qtpy.QtWidgets import QApplication, QSplitter, QTreeView +from qtpy.QtWidgets import QApplication, QSplitter -from pymmcore_widgets import ConfigGroupsEditor -from pymmcore_widgets.config_presets._qmodel._property_setting_delegate import ( - PropertySettingDelegate, -) +from pymmcore_widgets import ConfigGroupsEditor, ConfigGroupsTree app = QApplication([]) core = CMMCorePlus() @@ -15,12 +12,9 @@ cfg.setCurrentPreset("Channel", "FITC") # right-hand tree view showing the *same* model -tree = QTreeView() +tree = ConfigGroupsTree() tree.setModel(cfg._model) tree.expandRecursively(QModelIndex()) -tree.setColumnWidth(0, 180) -# make values in in the tree editable -tree.setItemDelegateForColumn(2, PropertySettingDelegate(tree)) splitter = QSplitter() diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py b/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py deleted file mode 100644 index d97f2883f..000000000 --- a/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py +++ /dev/null @@ -1,40 +0,0 @@ -from pymmcore_plus.model import Setting -from qtpy.QtCore import QAbstractItemModel, QModelIndex, Qt -from qtpy.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem, QWidget - -from pymmcore_widgets.device_properties import PropertyWidget - - -class PropertySettingDelegate(QStyledItemDelegate): - """Item delegate that uses a PropertyWidget for editing PropertySetting values.""" - - def createEditor( - self, parent: QWidget | None, option: QStyleOptionViewItem, index: QModelIndex - ) -> QWidget | None: - if not isinstance((setting := index.data(Qt.ItemDataRole.UserRole)), Setting): - return super().createEditor(parent, option, index) - dev, prop, *_ = setting - widget = PropertyWidget(dev, prop, parent=parent, connect_core=False) - widget.setValue(setting.property_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 not isinstance(setting, Setting) or not isinstance(editor, PropertyWidget): - super().setEditorData(editor, index) - return - - editor.setValue(setting.property_value) - - def setModelData( - self, - editor: QWidget | None, - model: QAbstractItemModel | None, - index: QModelIndex, - ) -> None: - if model and isinstance(editor, PropertyWidget): - model.setData(index, editor.value(), Qt.ItemDataRole.EditRole) - else: - super().setModelData(editor, model, index) From b6985fdcc47168db4c24a66204bd7fc556f98ee0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 2 Jul 2025 10:48:24 -0400 Subject: [PATCH 27/70] feat: Introduce QConfigGroupsModel for managing configuration groups and presets - Added QConfigGroupsModel to handle a three-level model structure for configuration groups, presets, and settings. - Implemented _Node class to represent tree nodes for groups, presets, and settings. - Enhanced data handling and editing capabilities within the model. - Updated related views and widgets to utilize the new model structure. - Refactored existing code to improve clarity and maintainability, including renaming and restructuring imports. - Adjusted tests to align with the new model and property handling. --- examples/config_groups_editor.py | 21 +- .../config_presets/__init__.py | 2 +- .../{_qmodel => _model}/__init__.py | 0 .../config_presets/_model/_py_config_model.py | 143 +++++ .../_q_config_model.py} | 50 +- .../_pixel_configuration_widget.py | 2 +- .../_views/_config_groups_editor.py | 487 ++++++++++++++++++ .../_views/_config_groups_tree.py | 2 +- .../_views/_config_presets_table.py | 2 +- .../config_presets/_views/_config_views.py | 20 +- .../_device_property_table.py | 8 +- tests/test_config_groups_model.py | 4 +- tests/test_config_groups_widgets.py | 2 +- 13 files changed, 689 insertions(+), 54 deletions(-) rename src/pymmcore_widgets/config_presets/{_qmodel => _model}/__init__.py (100%) create mode 100644 src/pymmcore_widgets/config_presets/_model/_py_config_model.py rename src/pymmcore_widgets/config_presets/{_qmodel/_config_model.py => _model/_q_config_model.py} (93%) create mode 100644 src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py diff --git a/examples/config_groups_editor.py b/examples/config_groups_editor.py index bb29d68e8..8afcedeb4 100644 --- a/examples/config_groups_editor.py +++ b/examples/config_groups_editor.py @@ -1,8 +1,9 @@ from pymmcore_plus import CMMCorePlus -from qtpy.QtCore import QModelIndex -from qtpy.QtWidgets import QApplication, QSplitter +from qtpy.QtWidgets import QApplication -from pymmcore_widgets import ConfigGroupsEditor, ConfigGroupsTree +from pymmcore_widgets.config_presets._views._config_groups_editor import ( + ConfigGroupsEditor, +) app = QApplication([]) core = CMMCorePlus() @@ -10,18 +11,6 @@ cfg = ConfigGroupsEditor.create_from_core(core) cfg.setCurrentPreset("Channel", "FITC") - -# right-hand tree view showing the *same* model -tree = ConfigGroupsTree() -tree.setModel(cfg._model) -tree.expandRecursively(QModelIndex()) - - -splitter = QSplitter() -splitter.addWidget(cfg) -splitter.addWidget(tree) -splitter.resize(1400, 800) -splitter.setSizes([900, 500]) -splitter.show() +cfg.show() app.exec() diff --git a/src/pymmcore_widgets/config_presets/__init__.py b/src/pymmcore_widgets/config_presets/__init__.py index ddfeb3077..94f291d0d 100644 --- a/src/pymmcore_widgets/config_presets/__init__.py +++ b/src/pymmcore_widgets/config_presets/__init__.py @@ -1,9 +1,9 @@ """Widgets related to configuration groups and presets.""" from ._group_preset_widget._group_preset_table_widget import GroupPresetTableWidget +from ._model._q_config_model import QConfigGroupsModel 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 from ._views._config_views import ConfigGroupsEditor diff --git a/src/pymmcore_widgets/config_presets/_qmodel/__init__.py b/src/pymmcore_widgets/config_presets/_model/__init__.py similarity index 100% rename from src/pymmcore_widgets/config_presets/_qmodel/__init__.py rename to src/pymmcore_widgets/config_presets/_model/__init__.py diff --git a/src/pymmcore_widgets/config_presets/_model/_py_config_model.py b/src/pymmcore_widgets/config_presets/_model/_py_config_model.py new file mode 100644 index 000000000..9d2a57d4f --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_model/_py_config_model.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +from pydantic import BaseModel, ConfigDict, Field, model_validator +from pymmcore_plus import CMMCorePlus, PropertyType +from typing_extensions import TypeAlias # py310 + +if TYPE_CHECKING: + from collections.abc import Iterable + +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 DeviceProperty(_BaseModel): + """One property on a device.""" + + device_label: str + 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 = None + + def as_tuple(self) -> tuple[str, str, str]: + """Return the property as a tuple.""" + return (self.device_label, self.property_name, self.value) + + @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 + + +class ConfigPreset(_BaseModel): + """Set of settings in a ConfigGroup.""" + + name: str + settings: list[DeviceProperty] = Field(default_factory=list) + + parent: ConfigGroup | None = None + + def add_setting(self, setting: DeviceProperty | dict) -> None: + """Add a setting to the preset.""" + _setting = DeviceProperty.model_validate(setting) + _setting.parent = self + self.settings.append(_setting) + + +class ConfigGroup(_BaseModel): + """A group of ConfigPresets.""" + + name: str + presets: dict[str, ConfigPreset] = Field(default_factory=dict) + + def add_preset(self, preset: ConfigPreset | dict) -> None: + """Add a preset to the group.""" + _preset = ConfigPreset.model_validate(preset) + _preset.parent = self + self.presets[_preset.name] = _preset + + +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] + + +DeviceProperty.model_rebuild() +ConfigPreset.model_rebuild() +ConfigGroup.model_rebuild() +PixelSizePreset.model_rebuild() +PixelSizeConfigs.model_rebuild() + +# ---------------------------------- + + +def get_config_groups(core: CMMCorePlus) -> Iterable[ConfigGroup]: + """Get the model for configuration groups.""" + for group in core.getAvailableConfigGroups(): + group_model = ConfigGroup(name=group) + for preset in core.getAvailableConfigs(group): + preset_model = ConfigPreset(name=preset) + for device, prop, value in core.getConfigData(group, preset): + max_len = 0 + limits = None + if core.isPropertySequenceable(device, prop): + max_len = core.getPropertySequenceMaxLength(device, prop) + if core.hasPropertyLimits(device, prop): + limits = ( + core.getPropertyLowerLimit(device, prop), + core.getPropertyUpperLimit(device, prop), + ) + + prop_model = DeviceProperty( + device_label=device, + property_name=prop, + value=value, + property_type=core.getPropertyType(device, prop), + is_read_only=core.isPropertyReadOnly(device, prop), + is_pre_init=core.isPropertyPreInit(device, prop), + allowed_values=core.getAllowedPropertyValues(device, prop), + sequence_max_length=max_len, + limits=limits, + ) + + preset_model.add_setting(prop_model) + group_model.add_preset(preset_model) + yield group_model diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py b/src/pymmcore_widgets/config_presets/_model/_q_config_model.py similarity index 93% rename from src/pymmcore_widgets/config_presets/_qmodel/_config_model.py rename to src/pymmcore_widgets/config_presets/_model/_q_config_model.py index 068011d67..fab0fef17 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_config_model.py +++ b/src/pymmcore_widgets/config_presets/_model/_q_config_model.py @@ -4,13 +4,19 @@ from enum import IntEnum from typing import TYPE_CHECKING, Any, cast, overload -from pymmcore_plus.model import ConfigGroup, ConfigPreset, Setting from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt from qtpy.QtGui import QFont, QIcon from qtpy.QtWidgets import QMessageBox from pymmcore_widgets._icons import get_device_icon +from ._py_config_model import ( + ConfigGroup, + ConfigPreset, + DeviceProperty, + get_config_groups, +) + if TYPE_CHECKING: from collections.abc import Iterable @@ -34,13 +40,13 @@ class _Node: @classmethod def create( cls, - payload: ConfigGroup | ConfigPreset | Setting, + payload: ConfigGroup | ConfigPreset | DeviceProperty, 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}" + if isinstance(payload, DeviceProperty): + name = f"{payload.device_label}-{payload.property_name}" else: name = payload.name @@ -57,7 +63,7 @@ def create( def __init__( self, name: str, - payload: ConfigGroup | ConfigPreset | Setting | None = None, + payload: ConfigGroup | ConfigPreset | DeviceProperty | None = None, parent: _Node | None = None, ) -> None: self.name = name @@ -82,7 +88,7 @@ def is_preset(self) -> bool: @property def is_setting(self) -> bool: - return isinstance(self.payload, Setting) + return isinstance(self.payload, DeviceProperty) class QConfigGroupsModel(QAbstractItemModel): @@ -90,7 +96,7 @@ class QConfigGroupsModel(QAbstractItemModel): @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__() @@ -170,21 +176,21 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A if node.is_preset: return QIcon.fromTheme("document") if node.is_setting: - setting = cast("Setting", node.payload) - if icon := get_device_icon(setting.device_name, color="gray"): + setting = cast("DeviceProperty", node.payload) + if icon := get_device_icon(setting.device_label, color="gray"): return icon.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) + setting = cast("DeviceProperty", node.payload) if index.column() == Col.Item: - return setting.device_name + return setting.device_label if index.column() == Col.Property: return setting.property_name if index.column() == Col.Value: - return setting.property_value + return setting.value # groups / presets: only show name elif index.column() == Col.Item: return node.name @@ -203,18 +209,20 @@ def setData( 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("DeviceProperty", 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 = DeviceProperty( + device_label=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.device_label, s.property_name) == (dev, prop): parent_preset.settings[i] = new_setting break else: @@ -325,7 +333,7 @@ def add_preset( raise ValueError("Reference index is not a ConfigGroup.") name = self._unique_child_name(group_node, base_name) - preset = ConfigPreset(name) + preset = ConfigPreset(name=name) row = len(group_node.children) if self.insertRows(row, 1, group_idx, _payloads=[preset]): return self.index(row, 0, group_idx) @@ -375,7 +383,9 @@ 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("DeviceProperty", n.payload) for n in parent_node.children + ] self.endRemoveRows() return True @@ -390,7 +400,7 @@ def remove(self, idx: QModelIndex) -> None: # 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[DeviceProperty] ) -> None: """Replace settings for `preset_idx` and update the tree safely.""" preset_node = self._node_from_index(preset_idx) @@ -470,7 +480,7 @@ def insertRows( count: int, parent: QModelIndex = NULL_INDEX, *, - _payloads: list[ConfigGroup | ConfigPreset | Setting] | None = None, + _payloads: list[ConfigGroup | ConfigPreset | DeviceProperty] | None = None, ) -> bool: """Insert *count* rows at *row* under *parent*. @@ -526,7 +536,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("DeviceProperty", payload)) pre.settings = settings self.endInsertRows() diff --git a/src/pymmcore_widgets/config_presets/_pixel_configuration_widget.py b/src/pymmcore_widgets/config_presets/_pixel_configuration_widget.py index f0cd3d28b..997949cfa 100644 --- a/src/pymmcore_widgets/config_presets/_pixel_configuration_widget.py +++ b/src/pymmcore_widgets/config_presets/_pixel_configuration_widget.py @@ -623,7 +623,7 @@ def value(self) -> list[Setting]: List of (device, property, value) to be checked in the DevicePropertyTable. """ return [ - Setting(dev, prop, val) + Setting(device_name=dev, property_name=prop, property_value=val) for dev, prop, val in self._prop_table.getCheckedProperties() ] diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py new file mode 100644 index 000000000..28aae3801 --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -0,0 +1,487 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from pymmcore_plus import DeviceProperty, DeviceType, Keyword +from qtpy.QtCore import QModelIndex, QSize, Qt, Signal +from qtpy.QtWidgets import ( + QGroupBox, + QHBoxLayout, + QLabel, + QListView, + QSplitter, + QToolBar, + QVBoxLayout, + QWidget, +) +from superqt import QIconifyIcon + +from pymmcore_widgets.config_presets._model._py_config_model import ( + ConfigGroup, + ConfigPreset, + get_config_groups, +) +from pymmcore_widgets.config_presets._model._q_config_model import ( + QConfigGroupsModel, + _Node, +) +from pymmcore_widgets.device_properties import DevicePropertyTable + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + from pymmcore_plus import CMMCorePlus + from PyQt6.QtGui import QAction +else: + from qtpy.QtGui import QAction + + +# ----------------------------------------------------------------------------- +# High-level editor widget +# ----------------------------------------------------------------------------- + + +class _NameList(QWidget): + """A group box that contains a toolbar and a QListView for cfg groups or presets.""" + + def __init__(self, title: str, parent: QWidget | None) -> None: + super().__init__(parent) + self._title = title + + # toolbar + self.list_view = QListView(self) + + self._toolbar = tb = QToolBar() + tb.setStyleSheet("QToolBar {background: none; border: none;}") + tb.setIconSize(QSize(18, 18)) + self._toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + + self.add_action = QAction( + QIconifyIcon("mdi:plus-thick", color="gray"), + f"Add {title.rstrip('s')}", + self, + ) + tb.addAction(self.add_action) + tb.addSeparator() + tb.addAction( + QIconifyIcon("mdi:remove-bold", color="gray"), "Remove", self._remove + ) + tb.addAction( + QIconifyIcon("mdi:content-duplicate", color="gray"), "Duplicate", self._dupe + ) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self._toolbar) + layout.addWidget(self.list_view) + + if isinstance(self, QGroupBox): + self.setTitle(title) + else: + label = QLabel(self._title, self) + font = label.font() + font.setBold(True) + label.setFont(font) + layout.insertWidget(0, label) + + def _is_groups(self) -> bool: + """Check if this box is for groups.""" + return bool(self._title == "Groups") + + def _remove(self) -> None: + self._model.remove(self.list_view.currentIndex()) + + @property + def _model(self) -> QConfigGroupsModel: + """Return the model used by this list view.""" + model = self.list_view.model() + if not isinstance(model, QConfigGroupsModel): + raise TypeError("Expected a QConfigGroupsModel instance.") + return model + + def _dupe(self) -> None: ... + + +class GroupsList(_NameList): + def __init__(self, parent: QWidget | None) -> None: + super().__init__("Groups", parent) + + def _dupe(self) -> None: + idx = self.list_view.currentIndex() + if idx.isValid(): + self.list_view.setCurrentIndex(self._model.duplicate_group(idx)) + + +class PresetsList(_NameList): + def __init__(self, parent: QWidget | None) -> None: + super().__init__("Presets", parent) + + # TODO: we need `_NameList.setCore()` in order to be able to "activate" a preset + self._toolbar.addAction( + QIconifyIcon("clarity:play-solid", color="gray"), + "Activate", + ) + + def _dupe(self) -> None: + idx = self.list_view.currentIndex() + if idx.isValid(): + self.list_view.setCurrentIndex(self._model.duplicate_preset(idx)) + + +class ConfigGroupsEditor(QWidget): + """Widget composed of two QListViews backed by a single tree model.""" + + configChanged = Signal() + + @classmethod + def create_from_core( + cls, core: CMMCorePlus, parent: QWidget | None = None + ) -> ConfigGroupsEditor: + """Create a ConfigGroupsEditor from a CMMCorePlus instance.""" + obj = cls(parent) + obj.update_from_core(core) + return obj + + def update_from_core( + self, + core: CMMCorePlus, + *, + update_configs: bool = True, + update_available: bool = True, + ) -> None: + """Update the editor's data from the core. + + Parameters + ---------- + core : CMMCorePlus + The core instance to pull configuration data from. + update_configs : bool + If True, update the entire list and states of config groups (i.e. make the + editor reflect the current state of config groups in the core). + update_available : bool + If True, update the available options in the property tables (for things + like "current device" comboboxes and other things that select from + available devices). + """ + if update_configs: + self.setData(get_config_groups(core)) + # if update_available: + # self._props._update_device_buttons(core) + # self._prop_tables.update_options_from_core(core) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._model = QConfigGroupsModel() + + # widgets -------------------------------------------------------------- + self._tb = QToolBar(self) + + self.group_list = QListView(self) + self.group_list.setModel(self._model) + self.group_list.setSelectionMode(QListView.SelectionMode.SingleSelection) + + self.preset_list = QListView(self) + self.preset_list.setModel(self._model) + self.preset_list.setSelectionMode(QListView.SelectionMode.SingleSelection) + + self._prop_selector = DevicePropertySelector() + + # layout ------------------------------------------------------------ + + top = QWidget(self) + top_layout = QHBoxLayout(top) + top_layout.setContentsMargins(0, 0, 0, 0) + top_layout.setSpacing(0) + top_layout.addWidget(self.group_list) + top_layout.addWidget(self.preset_list) + top_layout.addWidget(self._prop_selector) + + main_splitter = QSplitter(Qt.Orientation.Horizontal, self) + main_splitter.setHandleWidth(1) + main_splitter.setChildrenCollapsible(False) + main_splitter.addWidget(top) + # main_splitter.addWidget(preset_box) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self._tb) + layout.addWidget(main_splitter) + + # signals ------------------------------------------------------------ + + if sm := self.group_list.selectionModel(): + sm.currentChanged.connect(self._on_group_sel) + if sm := self.preset_list.selectionModel(): + sm.currentChanged.connect(self._on_preset_sel) + self._model.dataChanged.connect(self._on_model_data_changed) + # self._props.valueChanged.connect(self._on_prop_table_changed) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def setCurrentGroup(self, group: str) -> None: + """Set the currently selected group in the editor.""" + idx = self._model.index_for_group(group) + if idx.isValid(): + self.group_list.setCurrentIndex(idx) + else: + self.group_list.clearSelection() + + def setCurrentPreset(self, group: str, preset: str) -> None: + """Set the currently selected preset in the editor.""" + self.setCurrentGroup(group) + group_index = self._model.index_for_group(group) + idx = self._model.index_for_preset(group_index, preset) + if idx.isValid(): + self.preset_list.setCurrentIndex(idx) + self.preset_list.setFocus() + else: + self.preset_list.clearSelection() + + def setData(self, data: Iterable[ConfigGroup]) -> None: + """Set the configuration data to be displayed in the editor.""" + data = list(data) # ensure we can iterate multiple times + self._model.set_groups(data) + # self._props.setValue([]) + # Auto-select first group + if self._model.rowCount(): + self.group_list.setCurrentIndex(self._model.index(0)) + else: + self.preset_list.setRootIndex(QModelIndex()) + self.preset_list.clearSelection() + self.configChanged.emit() + + def data(self) -> Sequence[ConfigGroup]: + """Return the current configuration data as a list of ConfigGroup.""" + return self._model.get_groups() + + # selection sync --------------------------------------------------------- + + def _on_group_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: + self.preset_list.setRootIndex(current) + # self._props._presets_table.setGroup(current) + self.preset_list.clearSelection() + self._prop_selector.clear() + + def _on_preset_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: + """Populate the DevicePropertyTable whenever the selected preset changes.""" + if not current.isValid(): + # clear table when nothing is selected + # self._props.setValue([]) + return + node = cast("_Node", current.internalPointer()) + if not node.is_preset: + # self._props.setValue([]) + return + cast("ConfigPreset", node.payload) + # self._prop_selector.setChecked(preset.settings) + + # ------------------------------------------------------------------ + # Property-table sync + # ------------------------------------------------------------------ + + def _on_prop_table_changed(self) -> None: + """Write back edits from the table into the underlying ConfigPreset.""" + idx = self._preset_view.currentIndex() + if not idx.isValid(): + return + node = cast("_Node", idx.internalPointer()) + if not node.is_preset: + return + new_settings = self._props.value() + self._model.update_preset_settings(idx, new_settings) + self.configChanged.emit() + + def _on_model_data_changed( + self, + topLeft: QModelIndex, + bottomRight: QModelIndex, + _roles: list[int] | None = None, + ) -> None: + """Refresh DevicePropertyTable if a setting in the current preset was edited.""" + if not (preset := self._our_preset_changed_by_range(topLeft, bottomRight)): + return + + self._props.blockSignals(True) # avoid feedback loop + self._props.setValue(preset.settings) + self._props.blockSignals(False) + + def _our_preset_changed_by_range( + self, topLeft: QModelIndex, bottomRight: QModelIndex + ) -> ConfigPreset | None: + """Return our current preset if it was changed in the given range.""" + cur_preset = self._preset_view.currentIndex() + if ( + not cur_preset.isValid() + or not topLeft.isValid() + or topLeft.parent() != cur_preset.parent() + or topLeft.internalPointer().payload.name + != cur_preset.internalPointer().payload.name + ): + return None + + # pull updated settings from the model and push to the table + node = cast("_Node", self._preset_view.currentIndex().internalPointer()) + preset = cast("ConfigPreset", node.payload) + return preset + + +# class _PropSettings(QSplitter): +# """A wrapper for DevicePropertyTable for use in ConfigGroupsEditor.""" + +# valueChanged = Signal() + +# def __init__(self, parent: QWidget | None = None) -> None: +# super().__init__(Qt.Orientation.Vertical, parent) +# # 2D table with presets as columns and device properties as rows +# self._presets_table = ConfigPresetsTable(self) + +# # regular property table for editing all device properties +# self._prop_tables = DevicePropertyTable() +# self._prop_tables.valueChanged.connect(self.valueChanged) +# self._prop_tables.setRowsCheckable(True) + +# # toolbar with device type buttons +# self._action_group = QActionGroup(self) +# self._action_group.setExclusive(False) +# tb, self._action_group = self._create_device_buttons() + +# bot = QWidget() +# bl = QVBoxLayout(bot) +# bl.setContentsMargins(0, 0, 0, 0) +# bl.addWidget(tb) +# bl.addWidget(self._prop_tables) + +# self.addWidget(self._presets_table) +# self.addWidget(bot) + +# self._filter_properties() + +# def value(self) -> list[Setting]: +# """Return the current value of the property table.""" +# return self._prop_tables.value() + +# def setValue(self, value: list[Setting]) -> None: +# """Set the value of the property table.""" +# self._prop_tables.setValue(value) + +# def _create_device_buttons(self) -> tuple[QToolBar, QActionGroup]: +# tb = QToolBar() +# tb.setMovable(False) +# tb.setFloatable(False) +# tb.setIconSize(QSize(18, 18)) +# tb.setStyleSheet("QToolBar {background: none; border: none;}") +# tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) +# tb.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + +# # clear action group +# action_group = QActionGroup(self) +# action_group.setExclusive(False) + +# for dev_type, checked in { +# DeviceType.CameraDevice: False, +# DeviceType.ShutterDevice: True, +# DeviceType.StateDevice: True, +# DeviceType.StageDevice: False, +# DeviceType.XYStageDevice: False, +# DeviceType.SerialDevice: False, +# DeviceType.GenericDevice: False, +# DeviceType.AutoFocusDevice: False, +# DeviceType.ImageProcessorDevice: False, +# DeviceType.SignalIODevice: False, +# DeviceType.MagnifierDevice: False, +# DeviceType.SLMDevice: False, +# DeviceType.HubDevice: False, +# DeviceType.GalvoDevice: False, +# DeviceType.CoreDevice: False, +# }.items(): +# icon = QIconifyIcon(ICONS[dev_type], color="gray") +# if act := tb.addAction( +# icon, +# dev_type.name.replace("Device", ""), +# self._filter_properties, +# ): +# act.setCheckable(True) +# act.setChecked(checked) +# act.setData(dev_type) +# action_group.addAction(act) + +# return tb, action_group + +# def _filter_properties(self) -> None: +# include_devices = { +# action.data() +# for action in self._action_group.actions() +# if action.isChecked() +# } +# if not include_devices: +# # If no devices are selected, show all properties +# for row in range(self._prop_tables.rowCount()): +# self._prop_tables.hideRow(row) + +# else: +# self._prop_tables.filterDevices( +# include_pre_init=False, +# include_read_only=False, +# always_show_checked=True, +# include_devices=include_devices, +# predicate=_hide_state_state, +# ) + +# def _update_device_buttons(self, core: CMMCorePlus) -> None: +# for action in self._action_group.actions(): +# dev_type = cast("DeviceType", action.data()) +# for dev in core.getLoadedDevicesOfType(dev_type): +# writeable_props = ( +# ( +# not core.isPropertyPreInit(dev, prop) +# and not core.isPropertyReadOnly(dev, prop) +# ) +# for prop in core.getDevicePropertyNames(dev) +# ) +# if any(writeable_props): +# action.setVisible(True) +# break +# else: +# action.setVisible(False) + + +def _hide_state_state(prop: DeviceProperty) -> bool | None: + """Hide the State property for StateDevice (it duplicates state label).""" + if prop.deviceType() == DeviceType.StateDevice and prop.name == Keyword.State: + return False + return None + + +class DevicePropertySelector(QWidget): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + + self.table = DevicePropertyTable(self, connect_core=False) + self.table.filterDevices( + include_pre_init=False, + include_read_only=False, + always_show_checked=True, + # predicate=_hide_state_state, + ) + self.table.setRowsCheckable(True) + # hide the 2nd column (prop value) + self.table.setColumnHidden(1, True) + # hide header + if hh := self.table.horizontalHeader(): + hh.setVisible(False) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.table) + + def clear(self) -> None: + """Clear the current selection.""" + self.table.setValue([]) + + def setChecked(self, settings: Iterable[tuple[str, str, str]]) -> None: + """Set the checked state of the properties based on the given settings.""" + self.table.setValue(settings) 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..9179c75bb 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py @@ -4,7 +4,7 @@ from qtpy.QtWidgets import QTreeView, QWidget -from pymmcore_widgets.config_presets._qmodel._config_model import QConfigGroupsModel +from pymmcore_widgets.config_presets._model._q_config_model import QConfigGroupsModel from pymmcore_widgets.config_presets._views._property_setting_delegate import ( PropertySettingDelegate, ) 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..715476be8 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py @@ -17,7 +17,7 @@ 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.config_presets._model._q_config_model import QConfigGroupsModel from ._property_setting_delegate import PropertySettingDelegate diff --git a/src/pymmcore_widgets/config_presets/_views/_config_views.py b/src/pymmcore_widgets/config_presets/_views/_config_views.py index ef75990be..f976fc012 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_views.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_views.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING, cast from pymmcore_plus import DeviceProperty, DeviceType, Keyword -from pymmcore_plus.model import ConfigGroup from qtpy.QtCore import QModelIndex, QSize, Qt, Signal from qtpy.QtWidgets import ( QGroupBox, @@ -18,7 +17,15 @@ from superqt import QIconifyIcon from pymmcore_widgets._icons import ICONS -from pymmcore_widgets.config_presets._qmodel._config_model import ( +from pymmcore_widgets.config_presets._model._py_config_model import ( + ConfigGroup, + ConfigPreset, + get_config_groups, +) +from pymmcore_widgets.config_presets._model._py_config_model import ( + DeviceProperty as Setting, +) +from pymmcore_widgets.config_presets._model._q_config_model import ( QConfigGroupsModel, _Node, ) @@ -30,8 +37,8 @@ from collections.abc import Iterable, Sequence from pymmcore_plus import CMMCorePlus - from pymmcore_plus.model import ConfigPreset, Setting from PyQt6.QtGui import QAction, QActionGroup + else: from qtpy.QtGui import QAction, QActionGroup @@ -165,8 +172,7 @@ def update_from_core( available devices). """ if update_configs: - groups = ConfigGroup.all_config_groups(core) - self.setData(groups.values()) + self.setData(get_config_groups(core)) if update_available: self._props._update_device_buttons(core) # self._prop_tables.update_options_from_core(core) @@ -373,11 +379,11 @@ def __init__(self, parent: QWidget | None = None) -> None: def value(self) -> list[Setting]: """Return the current value of the property table.""" - return self._prop_tables.value() + return [Setting.model_validate(v) for v in self._prop_tables.value()] def setValue(self, value: list[Setting]) -> None: """Set the value of the property table.""" - self._prop_tables.setValue(value) + self._prop_tables.setValue([v.as_tuple() for v in value]) def _create_device_buttons(self) -> tuple[QToolBar, QActionGroup]: tb = QToolBar() diff --git a/src/pymmcore_widgets/device_properties/_device_property_table.py b/src/pymmcore_widgets/device_properties/_device_property_table.py index 2030c03b8..3f8058385 100644 --- a/src/pymmcore_widgets/device_properties/_device_property_table.py +++ b/src/pymmcore_widgets/device_properties/_device_property_table.py @@ -250,6 +250,10 @@ def filterDevices( if (item := self.item(row, 0)) is None: continue + if always_show_checked and item.checkState() == Qt.CheckState.Checked: + self.showRow(row) + continue + prop = cast("DeviceProperty", item.data(self.PROP_ROLE)) dev_type = prop.deviceType() if (include_devices and dev_type not in include_devices) or ( @@ -258,10 +262,6 @@ def filterDevices( self.hideRow(row) continue - if always_show_checked and item.checkState() == Qt.CheckState.Checked: - self.showRow(row) - continue - if ( (predicate and predicate(prop) is False) or (not include_read_only and prop.isReadOnly()) diff --git a/tests/test_config_groups_model.py b/tests/test_config_groups_model.py index 176c248ae..fb9cbaf9b 100644 --- a/tests/test_config_groups_model.py +++ b/tests/test_config_groups_model.py @@ -120,7 +120,7 @@ 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" @@ -215,7 +215,7 @@ 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) diff --git a/tests/test_config_groups_widgets.py b/tests/test_config_groups_widgets.py index ac09e70af..05dad95d2 100644 --- a/tests/test_config_groups_widgets.py +++ b/tests/test_config_groups_widgets.py @@ -8,7 +8,7 @@ from pymmcore_widgets import ConfigGroupsTree from pymmcore_widgets.config_presets import ConfigPresetsTable -from pymmcore_widgets.config_presets._qmodel._config_model import QConfigGroupsModel +from pymmcore_widgets.config_presets._model._q_config_model import QConfigGroupsModel from pymmcore_widgets.config_presets._views._property_setting_delegate import ( PropertySettingDelegate, ) From 3d590728fd009364c3296c408422417a54fa45bf Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 2 Jul 2025 12:21:29 -0400 Subject: [PATCH 28/70] feat: Add QConfigGroupsModel and related functionality for configuration management --- .../config_presets/_config_groups_editor.py | 0 .../config_presets/_model/_py_config_model.py | 4 + .../config_presets/_model/_q_config_model.py | 246 +++++++++++------- tests/test_config_groups_editor.py | 0 4 files changed, 149 insertions(+), 101 deletions(-) create mode 100644 src/pymmcore_widgets/config_presets/_config_groups_editor.py create mode 100644 tests/test_config_groups_editor.py diff --git a/src/pymmcore_widgets/config_presets/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_config_groups_editor.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pymmcore_widgets/config_presets/_model/_py_config_model.py b/src/pymmcore_widgets/config_presets/_model/_py_config_model.py index 9d2a57d4f..8c09e1f35 100644 --- a/src/pymmcore_widgets/config_presets/_model/_py_config_model.py +++ b/src/pymmcore_widgets/config_presets/_model/_py_config_model.py @@ -54,6 +54,10 @@ def _validate_input(cls, values: Any) -> Any: } return values + def display_name(self) -> str: + """Return a display name for the property.""" + return f"{self.device_label}-{self.property_name}" + class ConfigPreset(_BaseModel): """Set of settings in a ConfigGroup.""" diff --git a/src/pymmcore_widgets/config_presets/_model/_q_config_model.py b/src/pymmcore_widgets/config_presets/_model/_q_config_model.py index fab0fef17..0efb8315d 100644 --- a/src/pymmcore_widgets/config_presets/_model/_q_config_model.py +++ b/src/pymmcore_widgets/config_presets/_model/_q_config_model.py @@ -2,7 +2,7 @@ from copy import deepcopy from enum import IntEnum -from typing import TYPE_CHECKING, Any, cast, overload +from typing import TYPE_CHECKING, Any, cast from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt from qtpy.QtGui import QFont, QIcon @@ -46,7 +46,7 @@ def create( ) -> Self: """Create a new _Node with the given name and payload.""" if isinstance(payload, DeviceProperty): - name = f"{payload.device_label}-{payload.property_name}" + name = payload.display_name() else: name = payload.name @@ -91,7 +91,108 @@ def is_setting(self) -> bool: return isinstance(self.payload, DeviceProperty) -class QConfigGroupsModel(QAbstractItemModel): +# --------------------------------------------------------------------------- +# Reusable tree-model base providing all Qt plumbing +# --------------------------------------------------------------------------- +class _BaseTreeModel(QAbstractItemModel): + """Thin abstract tree model. + + Sub-classes implement five hooks: + + * _build_tree(self) -> _Node + * _column_count(self) -> int + * _data_for(self, node: _Node, column: int, role: int) -> Any + * _flags_for(self, node: _Node, column: int) -> Qt.ItemFlag + * _set_data(self, node: _Node, column: int, value: Any, role: int) -> bool. + """ + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self._root: _Node | None = None # created lazily + + # ---------- helpers --------------------------------------------------- + def _ensure_tree(self) -> None: + if self._root is None: + self._root = self._build_tree() + + # ---------- Qt plumbing ---------------------------------------------- + def index( + self, + row: int, + column: int, + parent: QModelIndex = QModelIndex(), + ) -> QModelIndex: + self._ensure_tree() + pnode = parent.internalPointer() if parent.isValid() else self._root + if 0 <= row < len(pnode.children): + return self.createIndex(row, column, pnode.children[row]) + return QModelIndex() + + def parent(self, child: QModelIndex) -> QModelIndex: + if not child.isValid(): + return QModelIndex() + node: _Node = child.internalPointer() + if node.parent is None or node.parent is self._root: + return QModelIndex() + return self.createIndex(node.parent.row_in_parent(), 0, node.parent) + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + self._ensure_tree() + if parent.column() > 0: + return 0 + node = parent.internalPointer() if parent.isValid() else self._root + return len(node.children) + + def columnCount(self, _parent: QModelIndex = QModelIndex()) -> int: + return self._column_count() + + def data(self, idx: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not idx.isValid(): + return None + return self._data_for(idx.internalPointer(), idx.column(), role) + + def flags(self, idx: QModelIndex) -> Qt.ItemFlag: + if not idx.isValid(): + return Qt.ItemFlag.NoItemFlags + return self._flags_for(idx.internalPointer(), idx.column()) + + def setData( + self, + idx: QModelIndex, + value: Any, + role: int = Qt.ItemDataRole.EditRole, + ) -> bool: + if not idx.isValid(): + return False + changed = self._set_data(idx.internalPointer(), idx.column(), value, role) + if changed: + self.dataChanged.emit(idx, idx, [role]) + return changed + + # ---------- hooks for subclasses ------------------------------------- + def _build_tree(self) -> _Node: # pragma: no cover + raise NotImplementedError + + def _column_count(self) -> int: + return 1 + + def _data_for(self, _node: _Node, _col: int, _role: int) -> Any: # pragma: no cover + raise NotImplementedError + + def _flags_for(self, _node: _Node, _col: int) -> Qt.ItemFlag: # pragma: no cover + raise NotImplementedError + + def _set_data( + self, + _node: _Node, + _col: int, + _value: Any, + _role: int, + ) -> bool: # pragma: no cover + return False + + +class QConfigGroupsModel(_BaseTreeModel): """Three-level model: root → groups → presets → settings.""" @classmethod @@ -99,78 +200,35 @@ def create_from_core(cls, core: CMMCorePlus) -> Self: return cls(get_config_groups(core)) def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: + self._groups: list[ConfigGroup] = list(groups) if groups else [] super().__init__() - self._root = _Node("", None) - if groups: - self.set_groups(groups) # ------------------------------------------------------------------ - # Required Qt model overrides + # _BaseTreeModel hooks # ------------------------------------------------------------------ + def _build_tree(self) -> _Node: + root = _Node("", None) + for grp in self._groups: + root.children.append(_Node.create(grp, root)) + return root - # 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. + def _column_count(self) -> int: 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: - """Return the data stored for `role` for the item at `index`.""" - node = self._node_from_index(index) + def _data_for(self, node: _Node, col: int, role: int) -> Any: if node is self._root: return None - # Qt.ItemDataRole.UserRole => return the original python object if role == Qt.ItemDataRole.UserRole: return node.payload - if role == Qt.ItemDataRole.FontRole and index.column() == Col.Item: + 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") if node.is_preset: @@ -179,47 +237,47 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A setting = cast("DeviceProperty", node.payload) if icon := get_device_icon(setting.device_label, color="gray"): return icon.pixmap(16, 16) - return QIcon.fromTheme("emblem-system") # pragma: no cover + return QIcon.fromTheme("emblem-system") if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - # settings: show Device, Property, Value if node.is_setting: setting = cast("DeviceProperty", node.payload) - if index.column() == Col.Item: + if col == Col.Item: return setting.device_label - if index.column() == Col.Property: + if col == Col.Property: return setting.property_name - if index.column() == Col.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 - def setData( - self, - index: QModelIndex, - value: Any, - role: int = Qt.ItemDataRole.EditRole, - ) -> bool: - node = self._node_from_index(index) + def _flags_for(self, node: _Node, col: int) -> Qt.ItemFlag: + if node is self._root: + return Qt.ItemFlag.NoItemFlags + + fl = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled + if node.is_setting and col == Col.Value: + fl |= Qt.ItemFlag.ItemIsEditable + elif not node.is_setting and col == Col.Item: + fl |= Qt.ItemFlag.ItemIsEditable + return fl + + def _set_data(self, node: _Node, col: int, value: Any, role: int) -> bool: if node is self._root or role != Qt.ItemDataRole.EditRole: - return False # pragma: no cover + return False + if node.is_setting: - if 0 > index.column() > 3: - return False # pragma: no cover + if col < 0 or col > 2: + return False dev, prop, val = cast("DeviceProperty", node.payload).as_tuple() - - # update node in place # FIXME ... this is hacky args = [dev, prop, val] - args[index.column()] = str(value) + args[col] = str(value) node.name = f"{args[0]}-{args[1]}" node.payload = new_setting = DeviceProperty( device_label=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.device_label, s.property_name) == (dev, prop): @@ -229,32 +287,16 @@ def setData( new_name = str(value).strip() if new_name == node.name or not new_name: 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 if isinstance(node.payload, (ConfigGroup, ConfigPreset)): - node.payload.name = new_name # keep dataclass in sync - - self.dataChanged.emit(index, index, [role]) + node.payload.name = new_name return True - def flags(self, index: QModelIndex) -> Qt.ItemFlag: - node = self._node_from_index(index) - if node is self._root: - return Qt.ItemFlag.NoItemFlags - - fl = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled - if node.is_setting and index.column() == Col.Value: - fl |= Qt.ItemFlag.ItemIsEditable - elif not node.is_setting and index.column() == Col.Item: - fl |= Qt.ItemFlag.ItemIsEditable - return fl - def headerData( self, section: int, @@ -275,6 +317,7 @@ def headerData( def index_for_group(self, group_name: str) -> QModelIndex: """Return the QModelIndex for the group with the given name.""" + self._ensure_tree() for i, node in enumerate(self._root.children): if node.is_group and node.name == group_name: return self.createIndex(i, 0, node) @@ -302,6 +345,7 @@ def index_for_preset( def add_group(self, base_name: str = "Group") -> QModelIndex: """Append a *new* empty group and return its QModelIndex.""" + self._ensure_tree() name = self._unique_child_name(self._root, base_name) group = ConfigGroup(name=name) row = self.rowCount() @@ -329,11 +373,11 @@ def add_preset( self, group_idx: QModelIndex, base_name: str = "Preset" ) -> QModelIndex: group_node = self._node_from_index(group_idx) - if not isinstance(group_node.payload, ConfigGroup): + if not isinstance((grp := group_node.payload), ConfigGroup): raise ValueError("Reference index is not a ConfigGroup.") name = self._unique_child_name(group_node, base_name) - preset = ConfigPreset(name=name) + preset = ConfigPreset(name=name, parent=grp) row = len(group_node.children) if self.insertRows(row, 1, group_idx, _payloads=[preset]): return self.index(row, 0, group_idx) @@ -447,9 +491,8 @@ def _name_exists(parent: _Node | None, name: str) -> bool: def set_groups(self, groups: Iterable[ConfigGroup]) -> None: """Clear model and set new groups.""" self.beginResetModel() - self._root.children.clear() - for g in groups: - self._root.children.append(_Node.create(g, self._root)) + self._groups = list(groups) + self._root = None # force rebuild self.endResetModel() def get_groups(self) -> list[ConfigGroup]: @@ -457,6 +500,7 @@ def get_groups(self) -> list[ConfigGroup]: return deepcopy([cast("ConfigGroup", n.payload) for n in self._root.children]) def _node_from_index(self, index: QModelIndex | None) -> _Node: + self._ensure_tree() if ( index and index.isValid() diff --git a/tests/test_config_groups_editor.py b/tests/test_config_groups_editor.py new file mode 100644 index 000000000..e69de29bb From 08a3099a50389222a1ec1942130e0e49f95dd119 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 2 Jul 2025 13:11:38 -0400 Subject: [PATCH 29/70] tmp --- src/pymmcore_widgets/_icons.py | 4 +- .../config_presets/_model/_base_tree_model.py | 168 ++++++++++++++++ .../config_presets/_model/_py_config_model.py | 187 ++++++++++++++---- .../config_presets/_model/_q_config_model.py | 161 +-------------- .../_views/_config_groups_editor.py | 99 +--------- .../config_presets/_views/_config_views.py | 7 +- .../_device_property_table.py | 4 +- src/pymmcore_widgets/hcwizard/devices_page.py | 10 +- 8 files changed, 336 insertions(+), 304 deletions(-) create mode 100644 src/pymmcore_widgets/config_presets/_model/_base_tree_model.py diff --git a/src/pymmcore_widgets/_icons.py b/src/pymmcore_widgets/_icons.py index 29a3c9fd6..24036cae6 100644 --- a/src/pymmcore_widgets/_icons.py +++ b/src/pymmcore_widgets/_icons.py @@ -3,7 +3,7 @@ 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", @@ -34,6 +34,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/config_presets/_model/_base_tree_model.py b/src/pymmcore_widgets/config_presets/_model/_base_tree_model.py new file mode 100644 index 000000000..717d170c0 --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_model/_base_tree_model.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from typing import Any + +from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt +from typing_extensions import Self + +from pymmcore_widgets.config_presets._model._py_config_model import ( + ConfigGroup, + ConfigPreset, + DeviceProperty, +) +from pymmcore_widgets.config_presets._model._q_config_model import NULL_INDEX + + +class _Node: + """Generic tree node that wraps a ConfigGroup, ConfigPreset, or Setting.""" + + @classmethod + def create( + cls, + payload: ConfigGroup | ConfigPreset | DeviceProperty, + parent: _Node | None = None, + recursive: bool = True, + ) -> Self: + """Create a new _Node with the given name and payload.""" + if isinstance(payload, DeviceProperty): + name = payload.display_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 | DeviceProperty | 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, DeviceProperty) + + +class _BaseTreeModel(QAbstractItemModel): + """Thin abstract tree model. + + Sub-classes implement five hooks: + + * _build_tree(self) -> _Node + * _column_count(self) -> int + * _data_for(self, node: _Node, column: int, role: int) -> Any + * _flags_for(self, node: _Node, column: int) -> Qt.ItemFlag + * _set_data(self, node: _Node, column: int, value: Any, role: int) -> bool. + """ + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self._root: _Node | None = None # created lazily + + # ---------- helpers --------------------------------------------------- + def _ensure_tree(self) -> None: + if self._root is None: + self._root = self._build_tree() + + # ---------- Qt plumbing ---------------------------------------------- + def index( + self, + row: int, + column: int = 0, + parent: QModelIndex = NULL_INDEX, + ) -> QModelIndex: + self._ensure_tree() + pnode = parent.internalPointer() if parent.isValid() else self._root + if 0 <= row < len(pnode.children): + return self.createIndex(row, column, pnode.children[row]) + return QModelIndex() + + def parent(self, child: QModelIndex) -> QModelIndex: + if not child.isValid(): + return QModelIndex() + node: _Node = child.internalPointer() + if node.parent is None or node.parent is self._root: + return QModelIndex() + return self.createIndex(node.parent.row_in_parent(), 0, node.parent) + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + self._ensure_tree() + if parent.column() > 0: + return 0 + node = parent.internalPointer() if parent.isValid() else self._root + return len(node.children) + + def columnCount(self, _parent: QModelIndex = QModelIndex()) -> int: + return self._column_count() + + def data(self, idx: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not idx.isValid(): + return None + return self._data_for(idx.internalPointer(), idx.column(), role) + + def flags(self, idx: QModelIndex) -> Qt.ItemFlag: + if not idx.isValid(): + return Qt.ItemFlag.NoItemFlags + return self._flags_for(idx.internalPointer(), idx.column()) + + def setData( + self, + idx: QModelIndex, + value: Any, + role: int = Qt.ItemDataRole.EditRole, + ) -> bool: + if not idx.isValid(): + return False + changed = self._set_data(idx.internalPointer(), idx.column(), value, role) + if changed: + self.dataChanged.emit(idx, idx, [role]) + return changed + + # ---------- hooks for subclasses ------------------------------------- + def _build_tree(self) -> _Node: # pragma: no cover + raise NotImplementedError + + def _column_count(self) -> int: + return 1 + + def _data_for(self, _node: _Node, _col: int, _role: int) -> Any: # pragma: no cover + raise NotImplementedError + + def _flags_for(self, _node: _Node, _col: int) -> Qt.ItemFlag: # pragma: no cover + raise NotImplementedError + + def _set_data( + self, + _node: _Node, + _col: int, + _value: Any, + _role: int, + ) -> bool: # pragma: no cover + return False diff --git a/src/pymmcore_widgets/config_presets/_model/_py_config_model.py b/src/pymmcore_widgets/config_presets/_model/_py_config_model.py index 8c09e1f35..701927132 100644 --- a/src/pymmcore_widgets/config_presets/_model/_py_config_model.py +++ b/src/pymmcore_widgets/config_presets/_model/_py_config_model.py @@ -1,13 +1,14 @@ from __future__ import annotations +from functools import cache from typing import TYPE_CHECKING, Any, ClassVar from pydantic import BaseModel, ConfigDict, Field, model_validator -from pymmcore_plus import CMMCorePlus, PropertyType +from pymmcore_plus import CMMCorePlus, DeviceType, PropertyType from typing_extensions import TypeAlias # py310 if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Container, Hashable, Iterable AffineTuple: TypeAlias = tuple[float, float, float, float, float, float] @@ -21,10 +22,39 @@ class _BaseModel(BaseModel): ) +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[DeviceProperty, ...] = Field(default_factory=tuple) + + @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) + + class DeviceProperty(_BaseModel): """One property on a device.""" - device_label: str + device: Device property_name: str value: str = "" @@ -37,6 +67,11 @@ class DeviceProperty(_BaseModel): parent: ConfigPreset | None = None + @property + def device_label(self) -> str: + """Return the label of the device.""" + return self.device.label + def as_tuple(self) -> tuple[str, str, str]: """Return the property as a tuple.""" return (self.device_label, self.property_name, self.value) @@ -67,12 +102,6 @@ class ConfigPreset(_BaseModel): parent: ConfigGroup | None = None - def add_setting(self, setting: DeviceProperty | dict) -> None: - """Add a setting to the preset.""" - _setting = DeviceProperty.model_validate(setting) - _setting.parent = self - self.settings.append(_setting) - class ConfigGroup(_BaseModel): """A group of ConfigPresets.""" @@ -80,12 +109,6 @@ class ConfigGroup(_BaseModel): name: str presets: dict[str, ConfigPreset] = Field(default_factory=dict) - def add_preset(self, preset: ConfigPreset | dict) -> None: - """Add a preset to the group.""" - _preset = ConfigPreset.model_validate(preset) - _preset.parent = self - self.presets[_preset.name] = _preset - class PixelSizePreset(ConfigPreset): """PixelSizePreset model.""" @@ -113,35 +136,115 @@ class PixelSizeConfigs(ConfigGroup): # ---------------------------------- +@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.""" for group in core.getAvailableConfigGroups(): group_model = ConfigGroup(name=group) - for preset in core.getAvailableConfigs(group): - preset_model = ConfigPreset(name=preset) - for device, prop, value in core.getConfigData(group, preset): - max_len = 0 - limits = None - if core.isPropertySequenceable(device, prop): - max_len = core.getPropertySequenceMaxLength(device, prop) - if core.hasPropertyLimits(device, prop): - limits = ( - core.getPropertyLowerLimit(device, prop), - core.getPropertyUpperLimit(device, prop), - ) - - prop_model = DeviceProperty( - device_label=device, - property_name=prop, - value=value, - property_type=core.getPropertyType(device, prop), - is_read_only=core.isPropertyReadOnly(device, prop), - is_pre_init=core.isPropertyPreInit(device, prop), - allowed_values=core.getAllowedPropertyValues(device, prop), - sequence_max_length=max_len, - limits=limits, - ) - - preset_model.add_setting(prop_model) - group_model.add_preset(preset_model) + 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[DeviceProperty]: + for device, prop, value in core.getConfigData(group, preset): + prop_model = DeviceProperty( + 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(DeviceProperty(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/config_presets/_model/_q_config_model.py b/src/pymmcore_widgets/config_presets/_model/_q_config_model.py index 0efb8315d..c213d4859 100644 --- a/src/pymmcore_widgets/config_presets/_model/_q_config_model.py +++ b/src/pymmcore_widgets/config_presets/_model/_q_config_model.py @@ -4,11 +4,15 @@ from enum import IntEnum from typing import TYPE_CHECKING, Any, cast -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 pymmcore_widgets._icons import get_device_icon +from pymmcore_widgets.config_presets._model._base_tree_model import ( + _BaseTreeModel, + _Node, +) from ._py_config_model import ( ConfigGroup, @@ -34,164 +38,9 @@ class Col(IntEnum): Value = 2 -class _Node: - """Generic tree node that wraps a ConfigGroup, ConfigPreset, or Setting.""" - - @classmethod - def create( - cls, - payload: ConfigGroup | ConfigPreset | DeviceProperty, - parent: _Node | None = None, - recursive: bool = True, - ) -> Self: - """Create a new _Node with the given name and payload.""" - if isinstance(payload, DeviceProperty): - name = payload.display_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 | DeviceProperty | 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, DeviceProperty) - - # --------------------------------------------------------------------------- # Reusable tree-model base providing all Qt plumbing # --------------------------------------------------------------------------- -class _BaseTreeModel(QAbstractItemModel): - """Thin abstract tree model. - - Sub-classes implement five hooks: - - * _build_tree(self) -> _Node - * _column_count(self) -> int - * _data_for(self, node: _Node, column: int, role: int) -> Any - * _flags_for(self, node: _Node, column: int) -> Qt.ItemFlag - * _set_data(self, node: _Node, column: int, value: Any, role: int) -> bool. - """ - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent) - self._root: _Node | None = None # created lazily - - # ---------- helpers --------------------------------------------------- - def _ensure_tree(self) -> None: - if self._root is None: - self._root = self._build_tree() - - # ---------- Qt plumbing ---------------------------------------------- - def index( - self, - row: int, - column: int, - parent: QModelIndex = QModelIndex(), - ) -> QModelIndex: - self._ensure_tree() - pnode = parent.internalPointer() if parent.isValid() else self._root - if 0 <= row < len(pnode.children): - return self.createIndex(row, column, pnode.children[row]) - return QModelIndex() - - def parent(self, child: QModelIndex) -> QModelIndex: - if not child.isValid(): - return QModelIndex() - node: _Node = child.internalPointer() - if node.parent is None or node.parent is self._root: - return QModelIndex() - return self.createIndex(node.parent.row_in_parent(), 0, node.parent) - - def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: - self._ensure_tree() - if parent.column() > 0: - return 0 - node = parent.internalPointer() if parent.isValid() else self._root - return len(node.children) - - def columnCount(self, _parent: QModelIndex = QModelIndex()) -> int: - return self._column_count() - - def data(self, idx: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: - if not idx.isValid(): - return None - return self._data_for(idx.internalPointer(), idx.column(), role) - - def flags(self, idx: QModelIndex) -> Qt.ItemFlag: - if not idx.isValid(): - return Qt.ItemFlag.NoItemFlags - return self._flags_for(idx.internalPointer(), idx.column()) - - def setData( - self, - idx: QModelIndex, - value: Any, - role: int = Qt.ItemDataRole.EditRole, - ) -> bool: - if not idx.isValid(): - return False - changed = self._set_data(idx.internalPointer(), idx.column(), value, role) - if changed: - self.dataChanged.emit(idx, idx, [role]) - return changed - - # ---------- hooks for subclasses ------------------------------------- - def _build_tree(self) -> _Node: # pragma: no cover - raise NotImplementedError - - def _column_count(self) -> int: - return 1 - - def _data_for(self, _node: _Node, _col: int, _role: int) -> Any: # pragma: no cover - raise NotImplementedError - - def _flags_for(self, _node: _Node, _col: int) -> Qt.ItemFlag: # pragma: no cover - raise NotImplementedError - - def _set_data( - self, - _node: _Node, - _col: int, - _value: Any, - _role: int, - ) -> bool: # pragma: no cover - return False - - class QConfigGroupsModel(_BaseTreeModel): """Three-level model: root → groups → presets → settings.""" diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index 28aae3801..76067818a 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -3,18 +3,15 @@ from typing import TYPE_CHECKING, cast from pymmcore_plus import DeviceProperty, DeviceType, Keyword -from qtpy.QtCore import QModelIndex, QSize, Qt, Signal +from qtpy.QtCore import QModelIndex, Qt, Signal from qtpy.QtWidgets import ( - QGroupBox, QHBoxLayout, - QLabel, QListView, QSplitter, QToolBar, QVBoxLayout, QWidget, ) -from superqt import QIconifyIcon from pymmcore_widgets.config_presets._model._py_config_model import ( ConfigGroup, @@ -23,7 +20,6 @@ ) from pymmcore_widgets.config_presets._model._q_config_model import ( QConfigGroupsModel, - _Node, ) from pymmcore_widgets.device_properties import DevicePropertyTable @@ -31,9 +27,10 @@ from collections.abc import Iterable, Sequence from pymmcore_plus import CMMCorePlus - from PyQt6.QtGui import QAction + + from pymmcore_widgets.config_presets._model._base_tree_model import _Node else: - from qtpy.QtGui import QAction + pass # ----------------------------------------------------------------------------- @@ -41,94 +38,6 @@ # ----------------------------------------------------------------------------- -class _NameList(QWidget): - """A group box that contains a toolbar and a QListView for cfg groups or presets.""" - - def __init__(self, title: str, parent: QWidget | None) -> None: - super().__init__(parent) - self._title = title - - # toolbar - self.list_view = QListView(self) - - self._toolbar = tb = QToolBar() - tb.setStyleSheet("QToolBar {background: none; border: none;}") - tb.setIconSize(QSize(18, 18)) - self._toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) - - self.add_action = QAction( - QIconifyIcon("mdi:plus-thick", color="gray"), - f"Add {title.rstrip('s')}", - self, - ) - tb.addAction(self.add_action) - tb.addSeparator() - tb.addAction( - QIconifyIcon("mdi:remove-bold", color="gray"), "Remove", self._remove - ) - tb.addAction( - QIconifyIcon("mdi:content-duplicate", color="gray"), "Duplicate", self._dupe - ) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - layout.addWidget(self._toolbar) - layout.addWidget(self.list_view) - - if isinstance(self, QGroupBox): - self.setTitle(title) - else: - label = QLabel(self._title, self) - font = label.font() - font.setBold(True) - label.setFont(font) - layout.insertWidget(0, label) - - def _is_groups(self) -> bool: - """Check if this box is for groups.""" - return bool(self._title == "Groups") - - def _remove(self) -> None: - self._model.remove(self.list_view.currentIndex()) - - @property - def _model(self) -> QConfigGroupsModel: - """Return the model used by this list view.""" - model = self.list_view.model() - if not isinstance(model, QConfigGroupsModel): - raise TypeError("Expected a QConfigGroupsModel instance.") - return model - - def _dupe(self) -> None: ... - - -class GroupsList(_NameList): - def __init__(self, parent: QWidget | None) -> None: - super().__init__("Groups", parent) - - def _dupe(self) -> None: - idx = self.list_view.currentIndex() - if idx.isValid(): - self.list_view.setCurrentIndex(self._model.duplicate_group(idx)) - - -class PresetsList(_NameList): - def __init__(self, parent: QWidget | None) -> None: - super().__init__("Presets", parent) - - # TODO: we need `_NameList.setCore()` in order to be able to "activate" a preset - self._toolbar.addAction( - QIconifyIcon("clarity:play-solid", color="gray"), - "Activate", - ) - - def _dupe(self) -> None: - idx = self.list_view.currentIndex() - if idx.isValid(): - self.list_view.setCurrentIndex(self._model.duplicate_preset(idx)) - - class ConfigGroupsEditor(QWidget): """Widget composed of two QListViews backed by a single tree model.""" diff --git a/src/pymmcore_widgets/config_presets/_views/_config_views.py b/src/pymmcore_widgets/config_presets/_views/_config_views.py index f976fc012..1af9b1ada 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_views.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_views.py @@ -16,7 +16,7 @@ ) from superqt import QIconifyIcon -from pymmcore_widgets._icons import ICONS +from pymmcore_widgets._icons import DEVICE_TYPE_ICON from pymmcore_widgets.config_presets._model._py_config_model import ( ConfigGroup, ConfigPreset, @@ -27,7 +27,6 @@ ) from pymmcore_widgets.config_presets._model._q_config_model import ( QConfigGroupsModel, - _Node, ) from pymmcore_widgets.device_properties import DevicePropertyTable @@ -39,6 +38,8 @@ from pymmcore_plus import CMMCorePlus from PyQt6.QtGui import QAction, QActionGroup + from pymmcore_widgets.config_presets._model._base_tree_model import _Node + else: from qtpy.QtGui import QAction, QActionGroup @@ -415,7 +416,7 @@ def _create_device_buttons(self) -> tuple[QToolBar, QActionGroup]: DeviceType.GalvoDevice: False, DeviceType.CoreDevice: False, }.items(): - icon = QIconifyIcon(ICONS[dev_type], color="gray") + icon = QIconifyIcon(DEVICE_TYPE_ICON[dev_type], color="gray") if act := tb.addAction( icon, dev_type.name.replace("Device", ""), diff --git a/src/pymmcore_widgets/device_properties/_device_property_table.py b/src/pymmcore_widgets/device_properties/_device_property_table.py index 3f8058385..d22b0bd1e 100644 --- a/src/pymmcore_widgets/device_properties/_device_property_table.py +++ b/src/pymmcore_widgets/device_properties/_device_property_table.py @@ -13,7 +13,7 @@ from superqt.iconify import QIconifyIcon from superqt.utils import signals_blocked -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 @@ -159,7 +159,7 @@ def _rebuild_table_inner(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)) From 071eba3e70f59a46bf9472b37a1b40ed4563cbdf Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 2 Jul 2025 13:50:46 -0400 Subject: [PATCH 30/70] wip --- .../config_presets/_model/_py_config_model.py | 45 +++- .../config_presets/_model/_q_config_model.py | 240 +++++++++++++----- .../_views/_config_presets_table.py | 18 +- tests/test_config_groups_model.py | 25 +- 4 files changed, 242 insertions(+), 86 deletions(-) diff --git a/src/pymmcore_widgets/config_presets/_model/_py_config_model.py b/src/pymmcore_widgets/config_presets/_model/_py_config_model.py index 701927132..1278694d4 100644 --- a/src/pymmcore_widgets/config_presets/_model/_py_config_model.py +++ b/src/pymmcore_widgets/config_presets/_model/_py_config_model.py @@ -3,7 +3,7 @@ from functools import cache from typing import TYPE_CHECKING, Any, ClassVar -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator from pymmcore_plus import CMMCorePlus, DeviceType, PropertyType from typing_extensions import TypeAlias # py310 @@ -50,11 +50,19 @@ 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 DeviceProperty(_BaseModel): """One property on a device.""" - device: Device + device: Device = Field(..., repr=False, exclude=True) property_name: str value: str = "" @@ -65,13 +73,18 @@ class DeviceProperty(_BaseModel): property_type: PropertyType = Field(default=PropertyType.Undef, frozen=True) sequence_max_length: int = Field(default=0, frozen=True) - parent: ConfigPreset | None = None + 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 + 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) @@ -93,6 +106,22 @@ 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, DeviceProperty): + 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.""" @@ -100,7 +129,15 @@ class ConfigPreset(_BaseModel): name: str settings: list[DeviceProperty] = Field(default_factory=list) - parent: ConfigGroup | None = None + 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 + ) class ConfigGroup(_BaseModel): diff --git a/src/pymmcore_widgets/config_presets/_model/_q_config_model.py b/src/pymmcore_widgets/config_presets/_model/_q_config_model.py index c213d4859..ff364b3e7 100644 --- a/src/pymmcore_widgets/config_presets/_model/_q_config_model.py +++ b/src/pymmcore_widgets/config_presets/_model/_q_config_model.py @@ -2,24 +2,27 @@ from copy import deepcopy from enum import IntEnum -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, cast, overload -from qtpy.QtCore import QModelIndex, Qt +from qtpy.QtCore import QAbstractItemModel, QModelIndex, Qt from qtpy.QtGui import QFont, QIcon from qtpy.QtWidgets import QMessageBox from pymmcore_widgets._icons import get_device_icon -from pymmcore_widgets.config_presets._model._base_tree_model import ( - _BaseTreeModel, - _Node, -) +# from pymmcore_widgets.config_presets._model._base_tree_model import ( +# _BaseTreeModel, +# _Node, +# ) from ._py_config_model import ( ConfigGroup, ConfigPreset, - DeviceProperty, + Device, get_config_groups, ) +from ._py_config_model import ( + DeviceProperty as Setting, +) if TYPE_CHECKING: from collections.abc import Iterable @@ -38,10 +41,64 @@ class Col(IntEnum): Value = 2 -# --------------------------------------------------------------------------- -# Reusable tree-model base providing all Qt plumbing -# --------------------------------------------------------------------------- -class QConfigGroupsModel(_BaseTreeModel): +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 = payload.display_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): """Three-level model: root → groups → presets → settings.""" @classmethod @@ -49,103 +106,162 @@ def create_from_core(cls, core: CMMCorePlus) -> Self: return cls(get_config_groups(core)) def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: - self._groups: list[ConfigGroup] = list(groups) if groups else [] super().__init__() + self._root = _Node("", None) + if groups: + self.set_groups(groups) # ------------------------------------------------------------------ - # _BaseTreeModel hooks + # Required Qt model overrides # ------------------------------------------------------------------ - def _build_tree(self) -> _Node: - root = _Node("", None) - for grp in self._groups: - root.children.append(_Node.create(grp, root)) - return root - def _column_count(self) -> int: + # 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 _data_for(self, node: _Node, col: int, role: int) -> Any: + 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: + """Return the data stored for `role` for the item at `index`.""" + node = self._node_from_index(index) if node is self._root: return None + # Qt.ItemDataRole.UserRole => return the original python object if role == Qt.ItemDataRole.UserRole: return node.payload - if role == Qt.ItemDataRole.FontRole and col == Col.Item: + if role == Qt.ItemDataRole.FontRole and index.column() == Col.Item: f = QFont() if node.is_group: f.setBold(True) return f - if role == Qt.ItemDataRole.DecorationRole and col == Col.Item: + if role == Qt.ItemDataRole.DecorationRole and index.column() == Col.Item: if node.is_group: return QIcon.fromTheme("folder") if node.is_preset: return QIcon.fromTheme("document") if node.is_setting: - setting = cast("DeviceProperty", node.payload) + setting = cast("Setting", node.payload) if icon := get_device_icon(setting.device_label, color="gray"): return icon.pixmap(16, 16) - return QIcon.fromTheme("emblem-system") + 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("DeviceProperty", node.payload) - if col == Col.Item: + setting = cast("Setting", node.payload) + if index.column() == Col.Item: return setting.device_label - if col == Col.Property: + if index.column() == Col.Property: return setting.property_name - if col == Col.Value: + if index.column() == Col.Value: return setting.value - elif col == Col.Item: + # groups / presets: only show name + elif index.column() == Col.Item: return node.name return None - def _flags_for(self, node: _Node, col: int) -> Qt.ItemFlag: - if node is self._root: - return Qt.ItemFlag.NoItemFlags - - fl = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled - if node.is_setting and col == Col.Value: - fl |= Qt.ItemFlag.ItemIsEditable - elif not node.is_setting and col == Col.Item: - fl |= Qt.ItemFlag.ItemIsEditable - return fl - - def _set_data(self, node: _Node, col: int, value: Any, role: int) -> bool: + def setData( + self, + index: QModelIndex, + value: Any, + role: int = Qt.ItemDataRole.EditRole, + ) -> bool: + node = self._node_from_index(index) if node is self._root or role != Qt.ItemDataRole.EditRole: - return False - + return False # pragma: no cover if node.is_setting: - if col < 0 or col > 2: - return False - dev, prop, val = cast("DeviceProperty", node.payload).as_tuple() + if 0 > index.column() > 3: + return False # pragma: no cover + dev, prop, val = cast("Setting", node.payload).as_tuple() + + # update node in place # FIXME ... this is hacky args = [dev, prop, val] - args[col] = str(value) + args[index.column()] = str(value) node.name = f"{args[0]}-{args[1]}" - node.payload = new_setting = DeviceProperty( - device_label=args[0], property_name=args[1], value=args[2] + node.payload = new_setting = Setting( + device=Device(label=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.device_label, s.property_name) == (dev, prop): + if s.as_tuple()[0:2] == (dev, prop): parent_preset.settings[i] = new_setting break else: new_name = str(value).strip() if new_name == node.name or not new_name: 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 if isinstance(node.payload, (ConfigGroup, ConfigPreset)): - node.payload.name = new_name + node.payload.name = new_name # keep dataclass in sync + + self.dataChanged.emit(index, index, [role]) return True + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + node = self._node_from_index(index) + if node is self._root: + return Qt.ItemFlag.NoItemFlags + + fl = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled + if node.is_setting and index.column() == Col.Value: + fl |= Qt.ItemFlag.ItemIsEditable + elif not node.is_setting and index.column() == Col.Item: + fl |= Qt.ItemFlag.ItemIsEditable + return fl + def headerData( self, section: int, @@ -166,7 +282,6 @@ def headerData( def index_for_group(self, group_name: str) -> QModelIndex: """Return the QModelIndex for the group with the given name.""" - self._ensure_tree() for i, node in enumerate(self._root.children): if node.is_group and node.name == group_name: return self.createIndex(i, 0, node) @@ -194,7 +309,6 @@ def index_for_preset( def add_group(self, base_name: str = "Group") -> QModelIndex: """Append a *new* empty group and return its QModelIndex.""" - self._ensure_tree() name = self._unique_child_name(self._root, base_name) group = ConfigGroup(name=name) row = self.rowCount() @@ -222,11 +336,11 @@ def add_preset( self, group_idx: QModelIndex, base_name: str = "Preset" ) -> QModelIndex: group_node = self._node_from_index(group_idx) - if not isinstance((grp := group_node.payload), ConfigGroup): + if not isinstance(group_node.payload, ConfigGroup): raise ValueError("Reference index is not a ConfigGroup.") name = self._unique_child_name(group_node, base_name) - preset = ConfigPreset(name=name, parent=grp) + preset = ConfigPreset(name=name) row = len(group_node.children) if self.insertRows(row, 1, group_idx, _payloads=[preset]): return self.index(row, 0, group_idx) @@ -276,9 +390,7 @@ def removeRows( if isinstance((p := n.payload), ConfigPreset) } elif isinstance((preset := parent_node.payload), ConfigPreset): - preset.settings = [ - cast("DeviceProperty", n.payload) for n in parent_node.children - ] + preset.settings = [cast("Setting", n.payload) for n in parent_node.children] self.endRemoveRows() return True @@ -293,7 +405,7 @@ def remove(self, idx: QModelIndex) -> None: # TODO: feels like this should be replaced with a more canonical method... def update_preset_settings( - self, preset_idx: QModelIndex, settings: list[DeviceProperty] + self, preset_idx: QModelIndex, settings: list[Setting] ) -> None: """Replace settings for `preset_idx` and update the tree safely.""" preset_node = self._node_from_index(preset_idx) @@ -340,8 +452,9 @@ def _name_exists(parent: _Node | None, name: str) -> bool: def set_groups(self, groups: Iterable[ConfigGroup]) -> None: """Clear model and set new groups.""" self.beginResetModel() - self._groups = list(groups) - self._root = None # force rebuild + self._root.children.clear() + for g in groups: + self._root.children.append(_Node.create(g, self._root)) self.endResetModel() def get_groups(self) -> list[ConfigGroup]: @@ -349,7 +462,6 @@ def get_groups(self) -> list[ConfigGroup]: return deepcopy([cast("ConfigGroup", n.payload) for n in self._root.children]) def _node_from_index(self, index: QModelIndex | None) -> _Node: - self._ensure_tree() if ( index and index.isValid() @@ -373,7 +485,7 @@ def insertRows( count: int, parent: QModelIndex = NULL_INDEX, *, - _payloads: list[ConfigGroup | ConfigPreset | DeviceProperty] | None = None, + _payloads: list[ConfigGroup | ConfigPreset | Setting] | None = None, ) -> bool: """Insert *count* rows at *row* under *parent*. @@ -429,7 +541,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("DeviceProperty", payload)) + settings.insert(row + i, cast("Setting", payload)) pre.settings = settings self.endInsertRows() 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 715476be8..74f9eee2e 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py @@ -3,7 +3,6 @@ from contextlib import suppress from typing import TYPE_CHECKING, Any -from pymmcore_plus.model import ConfigPreset, Setting from qtpy.QtCore import ( QAbstractItemModel, QAbstractTableModel, @@ -17,13 +16,16 @@ from superqt import QIconifyIcon from pymmcore_widgets._icons import get_device_icon +from pymmcore_widgets.config_presets._model._py_config_model import ConfigPreset +from pymmcore_widgets.config_presets._model._py_config_model import ( + DeviceProperty as Setting, +) from pymmcore_widgets.config_presets._model._q_config_model import 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 @@ -255,10 +257,12 @@ def setData( # Get the preset and device/property for this cell preset = self._presets[col] dev_prop = self._rows[row] - + breakpoint() # Create or update the setting # Update our local data - self._data[(row, col)] = setting = Setting(dev_prop[0], dev_prop[1], str(value)) + self._data[(row, col)] = setting = Setting( + device=dev_prop[0], property_name=dev_prop[1], value=str(value) + ) # Update the preset's settings list preset_settings = list(preset.settings) @@ -289,14 +293,14 @@ def _rebuild(self) -> None: # slot signature is flexible 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) + 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.device_name, s.property_name) == (device, prop): + if s.key() == (device, prop): self._data[(row, col)] = s break @@ -349,7 +353,7 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole, ): - return setting.property_value if setting else None + return setting.value if setting else None return None # make editable diff --git a/tests/test_config_groups_model.py b/tests/test_config_groups_model.py index fb9cbaf9b..56230595d 100644 --- a/tests/test_config_groups_model.py +++ b/tests/test_config_groups_model.py @@ -5,12 +5,19 @@ 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._model._py_config_model import ( + ConfigGroup, + ConfigPreset, + get_config_groups, +) +from pymmcore_widgets.config_presets._model._py_config_model import ( + DeviceProperty as Setting, +) from pymmcore_widgets.config_presets._views._config_presets_table import ( _ConfigGroupPivotModel, ) @@ -34,7 +41,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) @@ -122,7 +129,7 @@ def test_model_set_data(model: QConfigGroupsModel, qtbot: QtBot) -> None: assert preset0.name == "NewPresetName" 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) @@ -220,11 +227,7 @@ def test_update_preset_settings(model: QConfigGroupsModel, qtbot: QtBot) -> None 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", - ) + Setting(device="NewDevice", property_name="NewProperty", value="NewValue") ] model.update_preset_settings(preset0_index, new_settings) @@ -283,8 +286,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"), + Setting(device="Camera", property_name="Binning", value="8"), + Setting(device="Camera", property_name="BitDepth", value="14"), ] model.update_preset_settings(new_preset_idx, test_settings) @@ -317,7 +320,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 From bbe779bd31dc50504f915d1927ba27c9e75da28d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 2 Jul 2025 17:10:55 -0400 Subject: [PATCH 31/70] wip --- .../config_presets/_model/_py_config_model.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/pymmcore_widgets/config_presets/_model/_py_config_model.py b/src/pymmcore_widgets/config_presets/_model/_py_config_model.py index 1278694d4..7ce69a6dd 100644 --- a/src/pymmcore_widgets/config_presets/_model/_py_config_model.py +++ b/src/pymmcore_widgets/config_presets/_model/_py_config_model.py @@ -134,10 +134,7 @@ class ConfigPreset(_BaseModel): def __eq__(self, value: object) -> bool: if not isinstance(value, ConfigPreset): return False - return ( - self.name == value.name - and self.settings == value.settings - ) + return self.name == value.name and self.settings == value.settings class ConfigGroup(_BaseModel): From 0c5fae9adcef74ab8c55cf04ba541bab7fa0717a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 2 Jul 2025 18:39:28 -0400 Subject: [PATCH 32/70] Refactor configuration model and tree structure - Introduced a new base tree model (_BaseTreeModel) to handle tree structures for configuration groups and presets. - Created a new module (_py_config_model) to define configuration-related data models including Device, DevicePropertySetting, ConfigPreset, and ConfigGroup. - Implemented a QConfigGroupsModel to manage the interaction between the UI and the configuration data. - Removed the old base tree model implementation from the config_presets module. - Updated various views and widgets to utilize the new model structure, ensuring proper data handling and display. - Adjusted tests to reflect the new model organization and ensure functionality remains intact. --- pyproject.toml | 2 +- .../_model => _models}/__init__.py | 0 .../_models/_base_tree_model.py | 148 +++++++++++++++ .../_model => _models}/_py_config_model.py | 16 +- .../_model => _models}/_q_config_model.py | 163 +++-------------- .../config_presets/__init__.py | 2 - .../config_presets/_model/_base_tree_model.py | 168 ------------------ .../_views/_config_groups_editor.py | 6 +- .../_views/_config_groups_tree.py | 2 +- .../_views/_config_presets_table.py | 14 +- .../config_presets/_views/_config_views.py | 20 +-- tests/test_config_groups_model.py | 20 +-- tests/test_config_groups_widgets.py | 2 +- 13 files changed, 213 insertions(+), 350 deletions(-) rename src/pymmcore_widgets/{config_presets/_model => _models}/__init__.py (100%) create mode 100644 src/pymmcore_widgets/_models/_base_tree_model.py rename src/pymmcore_widgets/{config_presets/_model => _models}/_py_config_model.py (95%) rename src/pymmcore_widgets/{config_presets/_model => _models}/_q_config_model.py (74%) delete mode 100644 src/pymmcore_widgets/config_presets/_model/_base_tree_model.py 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", diff --git a/src/pymmcore_widgets/config_presets/_model/__init__.py b/src/pymmcore_widgets/_models/__init__.py similarity index 100% rename from src/pymmcore_widgets/config_presets/_model/__init__.py rename to src/pymmcore_widgets/_models/__init__.py 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..03161dd68 --- /dev/null +++ b/src/pymmcore_widgets/_models/_base_tree_model.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from typing import overload + +from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject +from typing_extensions import Self + +from pymmcore_widgets._models._py_config_model import ( + ConfigGroup, + ConfigPreset, + DevicePropertySetting, +) + +NULL_INDEX = QModelIndex() + + +class _Node: + """Generic tree node that wraps a ConfigGroup, ConfigPreset, or Setting.""" + + __slots__ = ( + "children", + "name", + "parent", + "payload", + ) + + @classmethod + def create( + cls, + payload: ConfigGroup | ConfigPreset | DevicePropertySetting, + parent: _Node | None = None, + recursive: bool = True, + ) -> Self: + """Create a new _Node with the given name and payload.""" + if isinstance(payload, DevicePropertySetting): + name = payload.display_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 | DevicePropertySetting | None = None, + parent: _Node | None = None, + ) -> None: + self.name = name + self.payload = payload + self.parent = parent + self.children: list[_Node] = [] + + # convenience ------------------------------------------------------------ + + def num_children(self) -> int: + return len(self.children) + + 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, DevicePropertySetting) + + +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/config_presets/_model/_py_config_model.py b/src/pymmcore_widgets/_models/_py_config_model.py similarity index 95% rename from src/pymmcore_widgets/config_presets/_model/_py_config_model.py rename to src/pymmcore_widgets/_models/_py_config_model.py index 7ce69a6dd..004f6d79e 100644 --- a/src/pymmcore_widgets/config_presets/_model/_py_config_model.py +++ b/src/pymmcore_widgets/_models/_py_config_model.py @@ -32,7 +32,7 @@ class Device(_BaseModel): description: str = Field(default="", frozen=True) type: DeviceType = Field(default=DeviceType.Unknown, frozen=True) - properties: tuple[DeviceProperty, ...] = Field(default_factory=tuple) + properties: tuple[DevicePropertySetting, ...] = Field(default_factory=tuple) @property def is_loaded(self) -> bool: @@ -59,7 +59,7 @@ def _validate_input(cls, values: Any) -> Any: return values -class DeviceProperty(_BaseModel): +class DevicePropertySetting(_BaseModel): """One property on a device.""" device: Device = Field(..., repr=False, exclude=True) @@ -108,7 +108,7 @@ def display_name(self) -> str: def __eq__(self, other: Any) -> bool: # deal with recursive equality checks - if not isinstance(other, DeviceProperty): + if not isinstance(other, DevicePropertySetting): return False return ( self.device_label == other.device_label @@ -127,7 +127,7 @@ class ConfigPreset(_BaseModel): """Set of settings in a ConfigGroup.""" name: str - settings: list[DeviceProperty] = Field(default_factory=list) + settings: list[DevicePropertySetting] = Field(default_factory=list) parent: ConfigGroup | None = Field(default=None, exclude=True, repr=False) @@ -161,7 +161,7 @@ class PixelSizeConfigs(ConfigGroup): presets: dict[str, PixelSizePreset] = Field(default_factory=dict) # type: ignore[assignment] -DeviceProperty.model_rebuild() +DevicePropertySetting.model_rebuild() ConfigPreset.model_rebuild() ConfigGroup.model_rebuild() PixelSizePreset.model_rebuild() @@ -204,9 +204,9 @@ def get_config_presets(core: CMMCorePlus, group: str) -> Iterable[ConfigPreset]: def get_preset_settings( core: CMMCorePlus, group: str, preset: str -) -> Iterable[DeviceProperty]: +) -> Iterable[DevicePropertySetting]: for device, prop, value in core.getConfigData(group, preset): - prop_model = DeviceProperty( + prop_model = DevicePropertySetting( device=_get_device(core, device), value=value, **get_property_info(core, device, prop), @@ -253,7 +253,7 @@ def get_loaded_devices(core: CMMCorePlus) -> Iterable[Device]: props = [] for prop in core.getDevicePropertyNames(label): prop_info = get_property_info(core, label, prop) - props.append(DeviceProperty(device=dev, **prop_info)) + props.append(DevicePropertySetting(device=dev, **prop_info)) dev.properties = tuple(props) yield dev diff --git a/src/pymmcore_widgets/config_presets/_model/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py similarity index 74% rename from src/pymmcore_widgets/config_presets/_model/_q_config_model.py rename to src/pymmcore_widgets/_models/_q_config_model.py index ff364b3e7..191e057b9 100644 --- a/src/pymmcore_widgets/config_presets/_model/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -2,27 +2,21 @@ from copy import deepcopy from enum import IntEnum -from typing import TYPE_CHECKING, Any, cast, overload +from typing import TYPE_CHECKING, Any, cast -from qtpy.QtCore import QAbstractItemModel, QModelIndex, Qt +from qtpy.QtCore import QModelIndex, Qt from qtpy.QtGui import QFont, QIcon from qtpy.QtWidgets import QMessageBox from pymmcore_widgets._icons import get_device_icon -# from pymmcore_widgets.config_presets._model._base_tree_model import ( -# _BaseTreeModel, -# _Node, -# ) +from ._base_tree_model import _BaseTreeModel, _Node from ._py_config_model import ( ConfigGroup, ConfigPreset, - Device, + DevicePropertySetting, get_config_groups, ) -from ._py_config_model import ( - DeviceProperty as Setting, -) if TYPE_CHECKING: from collections.abc import Iterable @@ -41,64 +35,7 @@ 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 = payload.display_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 @@ -107,7 +44,6 @@ def create_from_core(cls, core: CMMCorePlus) -> Self: def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: super().__init__() - self._root = _Node("", None) if groups: self.set_groups(groups) @@ -115,50 +51,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: @@ -171,19 +67,20 @@ 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") if node.is_preset: return QIcon.fromTheme("document") if node.is_setting: - setting = cast("Setting", node.payload) + setting = cast("DevicePropertySetting", node.payload) if icon := get_device_icon(setting.device_label, color="gray"): return icon.pixmap(16, 16) return QIcon.fromTheme("emblem-system") # pragma: no cover @@ -191,15 +88,15 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A 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: + setting = cast("DevicePropertySetting", node.payload) + if col == Col.Item: return setting.device_label - if index.column() == Col.Property: + if col == Col.Property: return setting.property_name - if index.column() == Col.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 @@ -216,14 +113,14 @@ def setData( if node.is_setting: if 0 > index.column() > 3: return False # pragma: no cover - dev, prop, val = cast("Setting", node.payload).as_tuple() + 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( - device=Device(label=args[0]), property_name=args[1], value=args[2] + node.payload = new_setting = DevicePropertySetting( + device=args[0], property_name=args[1], value=args[2] ) # also update the parent preset.settings list reference @@ -312,7 +209,7 @@ def add_group(self, base_name: str = "Group") -> QModelIndex: name = self._unique_child_name(self._root, base_name) 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 @@ -326,7 +223,7 @@ def duplicate_group( new_grp = deepcopy(grp) new_grp.name = new_name or 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 @@ -390,7 +287,9 @@ 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 @@ -405,7 +304,7 @@ def remove(self, idx: QModelIndex) -> None: # 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) @@ -461,17 +360,6 @@ 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 @@ -485,7 +373,8 @@ def insertRows( 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*. @@ -541,7 +430,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/config_presets/__init__.py b/src/pymmcore_widgets/config_presets/__init__.py index 94f291d0d..0b41fbc26 100644 --- a/src/pymmcore_widgets/config_presets/__init__.py +++ b/src/pymmcore_widgets/config_presets/__init__.py @@ -1,7 +1,6 @@ """Widgets related to configuration groups and presets.""" from ._group_preset_widget._group_preset_table_widget import GroupPresetTableWidget -from ._model._q_config_model import QConfigGroupsModel from ._objectives_pixel_configuration_widget import ObjectivesPixelConfigurationWidget from ._pixel_configuration_widget import PixelConfigurationWidget from ._views._config_groups_tree import ConfigGroupsTree @@ -15,5 +14,4 @@ "GroupPresetTableWidget", "ObjectivesPixelConfigurationWidget", "PixelConfigurationWidget", - "QConfigGroupsModel", ] diff --git a/src/pymmcore_widgets/config_presets/_model/_base_tree_model.py b/src/pymmcore_widgets/config_presets/_model/_base_tree_model.py deleted file mode 100644 index 717d170c0..000000000 --- a/src/pymmcore_widgets/config_presets/_model/_base_tree_model.py +++ /dev/null @@ -1,168 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt -from typing_extensions import Self - -from pymmcore_widgets.config_presets._model._py_config_model import ( - ConfigGroup, - ConfigPreset, - DeviceProperty, -) -from pymmcore_widgets.config_presets._model._q_config_model import NULL_INDEX - - -class _Node: - """Generic tree node that wraps a ConfigGroup, ConfigPreset, or Setting.""" - - @classmethod - def create( - cls, - payload: ConfigGroup | ConfigPreset | DeviceProperty, - parent: _Node | None = None, - recursive: bool = True, - ) -> Self: - """Create a new _Node with the given name and payload.""" - if isinstance(payload, DeviceProperty): - name = payload.display_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 | DeviceProperty | 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, DeviceProperty) - - -class _BaseTreeModel(QAbstractItemModel): - """Thin abstract tree model. - - Sub-classes implement five hooks: - - * _build_tree(self) -> _Node - * _column_count(self) -> int - * _data_for(self, node: _Node, column: int, role: int) -> Any - * _flags_for(self, node: _Node, column: int) -> Qt.ItemFlag - * _set_data(self, node: _Node, column: int, value: Any, role: int) -> bool. - """ - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent) - self._root: _Node | None = None # created lazily - - # ---------- helpers --------------------------------------------------- - def _ensure_tree(self) -> None: - if self._root is None: - self._root = self._build_tree() - - # ---------- Qt plumbing ---------------------------------------------- - def index( - self, - row: int, - column: int = 0, - parent: QModelIndex = NULL_INDEX, - ) -> QModelIndex: - self._ensure_tree() - pnode = parent.internalPointer() if parent.isValid() else self._root - if 0 <= row < len(pnode.children): - return self.createIndex(row, column, pnode.children[row]) - return QModelIndex() - - def parent(self, child: QModelIndex) -> QModelIndex: - if not child.isValid(): - return QModelIndex() - node: _Node = child.internalPointer() - if node.parent is None or node.parent is self._root: - return QModelIndex() - return self.createIndex(node.parent.row_in_parent(), 0, node.parent) - - def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: - self._ensure_tree() - if parent.column() > 0: - return 0 - node = parent.internalPointer() if parent.isValid() else self._root - return len(node.children) - - def columnCount(self, _parent: QModelIndex = QModelIndex()) -> int: - return self._column_count() - - def data(self, idx: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: - if not idx.isValid(): - return None - return self._data_for(idx.internalPointer(), idx.column(), role) - - def flags(self, idx: QModelIndex) -> Qt.ItemFlag: - if not idx.isValid(): - return Qt.ItemFlag.NoItemFlags - return self._flags_for(idx.internalPointer(), idx.column()) - - def setData( - self, - idx: QModelIndex, - value: Any, - role: int = Qt.ItemDataRole.EditRole, - ) -> bool: - if not idx.isValid(): - return False - changed = self._set_data(idx.internalPointer(), idx.column(), value, role) - if changed: - self.dataChanged.emit(idx, idx, [role]) - return changed - - # ---------- hooks for subclasses ------------------------------------- - def _build_tree(self) -> _Node: # pragma: no cover - raise NotImplementedError - - def _column_count(self) -> int: - return 1 - - def _data_for(self, _node: _Node, _col: int, _role: int) -> Any: # pragma: no cover - raise NotImplementedError - - def _flags_for(self, _node: _Node, _col: int) -> Qt.ItemFlag: # pragma: no cover - raise NotImplementedError - - def _set_data( - self, - _node: _Node, - _col: int, - _value: Any, - _role: int, - ) -> bool: # pragma: no cover - return False diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index 76067818a..a4b4008ed 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -13,12 +13,12 @@ QWidget, ) -from pymmcore_widgets.config_presets._model._py_config_model import ( +from pymmcore_widgets._models._py_config_model import ( ConfigGroup, ConfigPreset, get_config_groups, ) -from pymmcore_widgets.config_presets._model._q_config_model import ( +from pymmcore_widgets._models._q_config_model import ( QConfigGroupsModel, ) from pymmcore_widgets.device_properties import DevicePropertyTable @@ -28,7 +28,7 @@ from pymmcore_plus import CMMCorePlus - from pymmcore_widgets.config_presets._model._base_tree_model import _Node + from pymmcore_widgets._models._base_tree_model import _Node else: pass 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 9179c75bb..9cb82c7bb 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py @@ -4,7 +4,7 @@ from qtpy.QtWidgets import QTreeView, QWidget -from pymmcore_widgets.config_presets._model._q_config_model import QConfigGroupsModel +from pymmcore_widgets._models._q_config_model import QConfigGroupsModel from pymmcore_widgets.config_presets._views._property_setting_delegate import ( PropertySettingDelegate, ) 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 74f9eee2e..29d06afd4 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py @@ -16,17 +16,16 @@ from superqt import QIconifyIcon from pymmcore_widgets._icons import get_device_icon -from pymmcore_widgets.config_presets._model._py_config_model import ConfigPreset -from pymmcore_widgets.config_presets._model._py_config_model import ( - DeviceProperty as Setting, -) -from pymmcore_widgets.config_presets._model._q_config_model import QConfigGroupsModel +from pymmcore_widgets._models._py_config_model import DevicePropertySetting +from pymmcore_widgets._models._q_config_model import QConfigGroupsModel from ._property_setting_delegate import PropertySettingDelegate if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus from PyQt6.QtGui import QAction + + from pymmcore_widgets._models._py_config_model import ConfigPreset else: from qtpy.QtGui import QAction @@ -210,7 +209,7 @@ def __init__(self, parent: QWidget | None = 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] = {} + self._data: dict[tuple[int, int], DevicePropertySetting] = {} def sourceModel(self) -> QConfigGroupsModel | None: """Return the source model.""" @@ -257,10 +256,9 @@ def setData( # Get the preset and device/property for this cell preset = self._presets[col] dev_prop = self._rows[row] - breakpoint() # Create or update the setting # Update our local data - self._data[(row, col)] = setting = Setting( + self._data[(row, col)] = setting = DevicePropertySetting( device=dev_prop[0], property_name=dev_prop[1], value=str(value) ) diff --git a/src/pymmcore_widgets/config_presets/_views/_config_views.py b/src/pymmcore_widgets/config_presets/_views/_config_views.py index 1af9b1ada..57c81ebdb 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_views.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_views.py @@ -17,17 +17,13 @@ from superqt import QIconifyIcon from pymmcore_widgets._icons import DEVICE_TYPE_ICON -from pymmcore_widgets.config_presets._model._py_config_model import ( +from pymmcore_widgets._models._py_config_model import ( ConfigGroup, ConfigPreset, + DevicePropertySetting, get_config_groups, ) -from pymmcore_widgets.config_presets._model._py_config_model import ( - DeviceProperty as Setting, -) -from pymmcore_widgets.config_presets._model._q_config_model import ( - QConfigGroupsModel, -) +from pymmcore_widgets._models._q_config_model import QConfigGroupsModel from pymmcore_widgets.device_properties import DevicePropertyTable from ._config_presets_table import ConfigPresetsTable @@ -38,7 +34,7 @@ from pymmcore_plus import CMMCorePlus from PyQt6.QtGui import QAction, QActionGroup - from pymmcore_widgets.config_presets._model._base_tree_model import _Node + from pymmcore_widgets._models._base_tree_model import _Node else: from qtpy.QtGui import QAction, QActionGroup @@ -378,11 +374,13 @@ def __init__(self, parent: QWidget | None = None) -> None: self._filter_properties() - def value(self) -> list[Setting]: + def value(self) -> list[DevicePropertySetting]: """Return the current value of the property table.""" - return [Setting.model_validate(v) for v in self._prop_tables.value()] + return [ + DevicePropertySetting.model_validate(v) for v in self._prop_tables.value() + ] - def setValue(self, value: list[Setting]) -> None: + def setValue(self, value: list[DevicePropertySetting]) -> None: """Set the value of the property table.""" self._prop_tables.setValue([v.as_tuple() for v in value]) diff --git a/tests/test_config_groups_model.py b/tests/test_config_groups_model.py index 56230595d..de6748d21 100644 --- a/tests/test_config_groups_model.py +++ b/tests/test_config_groups_model.py @@ -9,15 +9,13 @@ from qtpy.QtGui import QFont, QIcon, QPixmap from qtpy.QtWidgets import QMessageBox -from pymmcore_widgets.config_presets import QConfigGroupsModel -from pymmcore_widgets.config_presets._model._py_config_model import ( +from pymmcore_widgets._models._py_config_model import ( ConfigGroup, ConfigPreset, + DevicePropertySetting, get_config_groups, ) -from pymmcore_widgets.config_presets._model._py_config_model import ( - DeviceProperty as Setting, -) +from pymmcore_widgets._models._q_config_model import QConfigGroupsModel from pymmcore_widgets.config_presets._views._config_presets_table import ( _ConfigGroupPivotModel, ) @@ -75,7 +73,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)), ], @@ -227,7 +225,9 @@ def test_update_preset_settings(model: QConfigGroupsModel, qtbot: QtBot) -> None grp0_index = model.index(0, 0) preset0_index = model.index(0, 0, grp0_index) new_settings = [ - Setting(device="NewDevice", property_name="NewProperty", value="NewValue") + DevicePropertySetting( + device="NewDevice", property_name="NewProperty", value="NewValue" + ) ] model.update_preset_settings(preset0_index, new_settings) @@ -286,8 +286,8 @@ def test_pivot_model_two_way_sync( # Add a setting to the new preset test_settings = [ - Setting(device="Camera", property_name="Binning", value="8"), - Setting(device="Camera", property_name="BitDepth", value="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) @@ -340,7 +340,7 @@ 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("Camera", "NewProperty", "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 05dad95d2..6c509dae3 100644 --- a/tests/test_config_groups_widgets.py +++ b/tests/test_config_groups_widgets.py @@ -7,8 +7,8 @@ from qtpy.QtCore import Qt from pymmcore_widgets import ConfigGroupsTree +from pymmcore_widgets._models._q_config_model import QConfigGroupsModel from pymmcore_widgets.config_presets import ConfigPresetsTable -from pymmcore_widgets.config_presets._model._q_config_model import QConfigGroupsModel from pymmcore_widgets.config_presets._views._property_setting_delegate import ( PropertySettingDelegate, ) From c22db0acac2058e2db1165e27134db1221710a51 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 2 Jul 2025 18:43:17 -0400 Subject: [PATCH 33/70] feat: Implement ConfigGroupPivotModel and update related references in the project --- src/pymmcore_widgets/_models/__init__.py | 29 +++ .../_models/_config_group_pivot_model.py | 187 ++++++++++++++++ .../_views/_config_presets_table.py | 200 +----------------- tests/test_config_groups_model.py | 12 +- 4 files changed, 231 insertions(+), 197 deletions(-) create mode 100644 src/pymmcore_widgets/_models/_config_group_pivot_model.py diff --git a/src/pymmcore_widgets/_models/__init__.py b/src/pymmcore_widgets/_models/__init__.py index e69de29bb..3399a94c3 100644 --- a/src/pymmcore_widgets/_models/__init__.py +++ b/src/pymmcore_widgets/_models/__init__.py @@ -0,0 +1,29 @@ +from ._config_group_pivot_model import ConfigGroupPivotModel +from ._py_config_model import ( + ConfigGroup, + ConfigPreset, + Device, + DevicePropertySetting, + PixelSizeConfigs, + PixelSizePreset, + get_config_groups, + get_config_presets, + get_preset_settings, + get_property_info, +) +from ._q_config_model import QConfigGroupsModel + +__all__ = [ + "ConfigGroup", + "ConfigGroupPivotModel", + "ConfigPreset", + "Device", + "DevicePropertySetting", + "PixelSizeConfigs", + "PixelSizePreset", + "QConfigGroupsModel", + "get_config_groups", + "get_config_presets", + "get_preset_settings", + "get_property_info", +] 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..41c3cc54c --- /dev/null +++ b/src/pymmcore_widgets/_models/_config_group_pivot_model.py @@ -0,0 +1,187 @@ +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 pymmcore_widgets._models._py_config_model import ( + ConfigPreset, + DevicePropertySetting, +) +from pymmcore_widgets._models._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._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 = 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, (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 = (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 + + 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.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/_config_presets_table.py b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py index 29d06afd4..265012063 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py @@ -1,11 +1,10 @@ from __future__ import annotations from contextlib import suppress -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from qtpy.QtCore import ( QAbstractItemModel, - QAbstractTableModel, QModelIndex, QSize, Qt, @@ -15,8 +14,9 @@ from qtpy.QtWidgets import QTableView, QToolBar, QVBoxLayout, QWidget from superqt import QIconifyIcon -from pymmcore_widgets._icons import get_device_icon -from pymmcore_widgets._models._py_config_model import DevicePropertySetting +from pymmcore_widgets._models._config_group_pivot_model import ( + ConfigGroupPivotModel, +) from pymmcore_widgets._models._q_config_model import QConfigGroupsModel from ._property_setting_delegate import PropertySettingDelegate @@ -25,7 +25,6 @@ from pymmcore_plus import CMMCorePlus from PyQt6.QtGui import QAction - from pymmcore_widgets._models._py_config_model import ConfigPreset else: from qtpy.QtGui import QAction @@ -44,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( @@ -73,11 +72,11 @@ def stretchHeaders(self) -> None: hh.setSectionResizeMode(col, hh.ResizeMode.Stretch) self._have_stretched_headers = True - def _get_pivot_model(self) -> _ConfigGroupPivotModel: + 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 @@ -96,7 +95,7 @@ def setGroup(self, group_name_or_index: str | QModelIndex) -> None: 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) @@ -195,182 +194,3 @@ def _get_selected_preset_index(self) -> QModelIndex: 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], 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._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 = 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, (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 = (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 - - 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.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/tests/test_config_groups_model.py b/tests/test_config_groups_model.py index de6748d21..4f90af743 100644 --- a/tests/test_config_groups_model.py +++ b/tests/test_config_groups_model.py @@ -9,16 +9,14 @@ from qtpy.QtGui import QFont, QIcon, QPixmap from qtpy.QtWidgets import QMessageBox -from pymmcore_widgets._models._py_config_model import ( +from pymmcore_widgets._models import ( ConfigGroup, + ConfigGroupPivotModel, ConfigPreset, DevicePropertySetting, + QConfigGroupsModel, get_config_groups, ) -from pymmcore_widgets._models._q_config_model import QConfigGroupsModel -from pymmcore_widgets.config_presets._views._config_presets_table import ( - _ConfigGroupPivotModel, -) if TYPE_CHECKING: from pytestqt.modeltest import ModelTester @@ -247,7 +245,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) @@ -258,7 +256,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) From 3a1db3ce0a4e9baf6aad183b02883c5422431cd9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 2 Jul 2025 18:47:02 -0400 Subject: [PATCH 34/70] fix test --- src/pymmcore_widgets/_models/_config_group_pivot_model.py | 8 ++++++-- tests/test_config_groups_model.py | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/pymmcore_widgets/_models/_config_group_pivot_model.py b/src/pymmcore_widgets/_models/_config_group_pivot_model.py index 41c3cc54c..8b68156b5 100644 --- a/src/pymmcore_widgets/_models/_config_group_pivot_model.py +++ b/src/pymmcore_widgets/_models/_config_group_pivot_model.py @@ -77,8 +77,12 @@ def setData( 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: + 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: diff --git a/tests/test_config_groups_model.py b/tests/test_config_groups_model.py index 4f90af743..392a7580f 100644 --- a/tests/test_config_groups_model.py +++ b/tests/test_config_groups_model.py @@ -338,7 +338,9 @@ def test_pivot_model_two_way_sync( # Add a new setting that doesn't exist in other presets new_settings = [ *medres_preset.settings, - DevicePropertySetting("Camera", "NewProperty", "NewValue"), + DevicePropertySetting( + device="Camera", property_name="NewProperty", value="NewValue" + ), ] model.update_preset_settings(medres_preset_idx, new_settings) From f0bb83a38cf07b2b25279217236f137feb12d1fb Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 2 Jul 2025 19:36:45 -0400 Subject: [PATCH 35/70] refactor: Consolidate imports and move core functions to a new module --- src/pymmcore_widgets/_models/__init__.py | 10 +- .../_models/_base_tree_model.py | 6 +- .../_models/_config_group_pivot_model.py | 4 +- .../_models/_core_functions.py | 128 ++++++++++++++++++ .../_models/_py_config_model.py | 123 +---------------- .../_models/_q_config_model.py | 8 +- .../_views/_config_groups_editor.py | 6 +- .../_views/_config_groups_tree.py | 2 +- .../_views/_config_presets_table.py | 5 +- .../config_presets/_views/_config_views.py | 4 +- tests/test_config_groups_widgets.py | 2 +- 11 files changed, 149 insertions(+), 149 deletions(-) create mode 100644 src/pymmcore_widgets/_models/_core_functions.py diff --git a/src/pymmcore_widgets/_models/__init__.py b/src/pymmcore_widgets/_models/__init__.py index 3399a94c3..b9fd9126a 100644 --- a/src/pymmcore_widgets/_models/__init__.py +++ b/src/pymmcore_widgets/_models/__init__.py @@ -1,4 +1,10 @@ from ._config_group_pivot_model import ConfigGroupPivotModel +from ._core_functions import ( + get_config_groups, + get_config_presets, + get_preset_settings, + get_property_info, +) from ._py_config_model import ( ConfigGroup, ConfigPreset, @@ -6,10 +12,6 @@ DevicePropertySetting, PixelSizeConfigs, PixelSizePreset, - get_config_groups, - get_config_presets, - get_preset_settings, - get_property_info, ) from ._q_config_model import QConfigGroupsModel diff --git a/src/pymmcore_widgets/_models/_base_tree_model.py b/src/pymmcore_widgets/_models/_base_tree_model.py index 03161dd68..0aa3ef21e 100644 --- a/src/pymmcore_widgets/_models/_base_tree_model.py +++ b/src/pymmcore_widgets/_models/_base_tree_model.py @@ -5,11 +5,7 @@ from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject from typing_extensions import Self -from pymmcore_widgets._models._py_config_model import ( - ConfigGroup, - ConfigPreset, - DevicePropertySetting, -) +from pymmcore_widgets._models import ConfigGroup, ConfigPreset, DevicePropertySetting NULL_INDEX = QModelIndex() diff --git a/src/pymmcore_widgets/_models/_config_group_pivot_model.py b/src/pymmcore_widgets/_models/_config_group_pivot_model.py index 8b68156b5..b5d812595 100644 --- a/src/pymmcore_widgets/_models/_config_group_pivot_model.py +++ b/src/pymmcore_widgets/_models/_config_group_pivot_model.py @@ -4,11 +4,11 @@ from qtpy.QtWidgets import QWidget from pymmcore_widgets._icons import get_device_icon -from pymmcore_widgets._models._py_config_model import ( +from pymmcore_widgets._models import ( ConfigPreset, DevicePropertySetting, + QConfigGroupsModel, ) -from pymmcore_widgets._models._q_config_model import QConfigGroupsModel class ConfigGroupPivotModel(QAbstractTableModel): diff --git a/src/pymmcore_widgets/_models/_core_functions.py b/src/pymmcore_widgets/_models/_core_functions.py new file mode 100644 index 000000000..cc23de9c7 --- /dev/null +++ b/src/pymmcore_widgets/_models/_core_functions.py @@ -0,0 +1,128 @@ +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.""" + for group in core.getAvailableConfigGroups(): + group_model = ConfigGroup(name=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 index 004f6d79e..1c8fc3be3 100644 --- a/src/pymmcore_widgets/_models/_py_config_model.py +++ b/src/pymmcore_widgets/_models/_py_config_model.py @@ -1,14 +1,13 @@ from __future__ import annotations -from functools import cache from typing import TYPE_CHECKING, Any, ClassVar from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator -from pymmcore_plus import CMMCorePlus, DeviceType, PropertyType -from typing_extensions import TypeAlias # py310 +from pymmcore_plus import DeviceType, PropertyType +from typing_extensions import TypeAlias if TYPE_CHECKING: - from collections.abc import Container, Hashable, Iterable + from collections.abc import Hashable AffineTuple: TypeAlias = tuple[float, float, float, float, float, float] @@ -166,119 +165,3 @@ class PixelSizeConfigs(ConfigGroup): ConfigGroup.model_rebuild() PixelSizePreset.model_rebuild() PixelSizeConfigs.model_rebuild() - -# ---------------------------------- - - -@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.""" - for group in core.getAvailableConfigGroups(): - group_model = ConfigGroup(name=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/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py index 191e057b9..9b74d873d 100644 --- a/src/pymmcore_widgets/_models/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -11,12 +11,8 @@ from pymmcore_widgets._icons import get_device_icon from ._base_tree_model import _BaseTreeModel, _Node -from ._py_config_model import ( - ConfigGroup, - ConfigPreset, - DevicePropertySetting, - get_config_groups, -) +from ._core_functions import get_config_groups +from ._py_config_model import ConfigGroup, ConfigPreset, DevicePropertySetting if TYPE_CHECKING: from collections.abc import Iterable diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index a4b4008ed..d392cda80 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -13,13 +13,11 @@ QWidget, ) -from pymmcore_widgets._models._py_config_model import ( +from pymmcore_widgets._models import ( ConfigGroup, ConfigPreset, - get_config_groups, -) -from pymmcore_widgets._models._q_config_model import ( QConfigGroupsModel, + get_config_groups, ) from pymmcore_widgets.device_properties import DevicePropertyTable 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 9cb82c7bb..fe1bb09ee 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py @@ -4,7 +4,7 @@ from qtpy.QtWidgets import QTreeView, QWidget -from pymmcore_widgets._models._q_config_model import QConfigGroupsModel +from pymmcore_widgets._models import QConfigGroupsModel from pymmcore_widgets.config_presets._views._property_setting_delegate import ( PropertySettingDelegate, ) 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 265012063..12653cd52 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py @@ -14,10 +14,7 @@ from qtpy.QtWidgets import QTableView, QToolBar, QVBoxLayout, QWidget from superqt import QIconifyIcon -from pymmcore_widgets._models._config_group_pivot_model import ( - ConfigGroupPivotModel, -) -from pymmcore_widgets._models._q_config_model import QConfigGroupsModel +from pymmcore_widgets._models import ConfigGroupPivotModel, QConfigGroupsModel from ._property_setting_delegate import PropertySettingDelegate diff --git a/src/pymmcore_widgets/config_presets/_views/_config_views.py b/src/pymmcore_widgets/config_presets/_views/_config_views.py index 57c81ebdb..09d03c6d6 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_views.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_views.py @@ -17,13 +17,13 @@ from superqt import QIconifyIcon from pymmcore_widgets._icons import DEVICE_TYPE_ICON -from pymmcore_widgets._models._py_config_model import ( +from pymmcore_widgets._models import ( ConfigGroup, ConfigPreset, DevicePropertySetting, + QConfigGroupsModel, get_config_groups, ) -from pymmcore_widgets._models._q_config_model import QConfigGroupsModel from pymmcore_widgets.device_properties import DevicePropertyTable from ._config_presets_table import ConfigPresetsTable diff --git a/tests/test_config_groups_widgets.py b/tests/test_config_groups_widgets.py index 6c509dae3..2d717edb3 100644 --- a/tests/test_config_groups_widgets.py +++ b/tests/test_config_groups_widgets.py @@ -7,7 +7,7 @@ from qtpy.QtCore import Qt from pymmcore_widgets import ConfigGroupsTree -from pymmcore_widgets._models._q_config_model import QConfigGroupsModel +from pymmcore_widgets._models import QConfigGroupsModel from pymmcore_widgets.config_presets import ConfigPresetsTable from pymmcore_widgets.config_presets._views._property_setting_delegate import ( PropertySettingDelegate, From ab99162d3b518ee4e76f4aa0214fbd0b4af1b823 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 2 Jul 2025 19:45:18 -0400 Subject: [PATCH 36/70] fixes --- src/pymmcore_widgets/_models/_base_tree_model.py | 2 +- src/pymmcore_widgets/_models/_config_group_pivot_model.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/pymmcore_widgets/_models/_base_tree_model.py b/src/pymmcore_widgets/_models/_base_tree_model.py index 0aa3ef21e..40a0e4a7f 100644 --- a/src/pymmcore_widgets/_models/_base_tree_model.py +++ b/src/pymmcore_widgets/_models/_base_tree_model.py @@ -5,7 +5,7 @@ from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject from typing_extensions import Self -from pymmcore_widgets._models import ConfigGroup, ConfigPreset, DevicePropertySetting +from ._py_config_model import ConfigGroup, ConfigPreset, DevicePropertySetting NULL_INDEX = QModelIndex() diff --git a/src/pymmcore_widgets/_models/_config_group_pivot_model.py b/src/pymmcore_widgets/_models/_config_group_pivot_model.py index b5d812595..68c55cb9b 100644 --- a/src/pymmcore_widgets/_models/_config_group_pivot_model.py +++ b/src/pymmcore_widgets/_models/_config_group_pivot_model.py @@ -4,11 +4,9 @@ from qtpy.QtWidgets import QWidget from pymmcore_widgets._icons import get_device_icon -from pymmcore_widgets._models import ( - ConfigPreset, - DevicePropertySetting, - QConfigGroupsModel, -) + +from ._py_config_model import ConfigPreset, DevicePropertySetting +from ._q_config_model import QConfigGroupsModel class ConfigGroupPivotModel(QAbstractTableModel): From f44b9c0d79481514c8793c503d1ccc0aa5f04a77 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 2 Jul 2025 21:59:17 -0400 Subject: [PATCH 37/70] feat: Enhance device property management by adding QDevicePropertyModel and integrating it into the ConfigGroupsEditor --- src/pymmcore_widgets/_models/__init__.py | 6 + .../_models/_base_tree_model.py | 25 +- .../_models/_q_device_prop_model.py | 238 ++++++++++++++++++ .../_views/_config_groups_editor.py | 196 ++++----------- 4 files changed, 308 insertions(+), 157 deletions(-) create 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 b9fd9126a..8c24ac8e7 100644 --- a/src/pymmcore_widgets/_models/__init__.py +++ b/src/pymmcore_widgets/_models/__init__.py @@ -1,7 +1,9 @@ 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, ) @@ -14,6 +16,7 @@ PixelSizePreset, ) from ._q_config_model import QConfigGroupsModel +from ._q_device_prop_model import QDevicePropertyModel __all__ = [ "ConfigGroup", @@ -24,8 +27,11 @@ "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 index 40a0e4a7f..d82a60da9 100644 --- a/src/pymmcore_widgets/_models/_base_tree_model.py +++ b/src/pymmcore_widgets/_models/_base_tree_model.py @@ -2,10 +2,10 @@ from typing import overload -from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject +from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt from typing_extensions import Self -from ._py_config_model import ConfigGroup, ConfigPreset, DevicePropertySetting +from ._py_config_model import ConfigGroup, ConfigPreset, Device, DevicePropertySetting NULL_INDEX = QModelIndex() @@ -14,6 +14,7 @@ class _Node: """Generic tree node that wraps a ConfigGroup, ConfigPreset, or Setting.""" __slots__ = ( + "check_state", "children", "name", "parent", @@ -23,13 +24,15 @@ class _Node: @classmethod def create( cls, - payload: ConfigGroup | ConfigPreset | DevicePropertySetting, + 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.display_name() + name = payload.property_name + elif isinstance(payload, Device): + name = payload.label else: name = payload.name @@ -41,17 +44,25 @@ def create( 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 | None = None, + 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 ------------------------------------------------------------ @@ -76,6 +87,10 @@ def is_preset(self) -> bool: 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. 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..ce0b40546 --- /dev/null +++ b/src/pymmcore_widgets/_models/_q_device_prop_model.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING, Any, cast + +from pymmcore_plus import PropertyType +from qtpy.QtCore import ( + QAbstractItemModel, + QAbstractProxyModel, + QModelIndex, + QObject, + QPersistentModelIndex, + 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 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) + index.column() + if node is self._root: + return None + + if index.column() == 1: + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): + if isinstance(device := node.payload, Device): + return device.type.name + elif isinstance(setting := node.payload, DevicePropertySetting): + return setting.property_type.name + elif role == Qt.ItemDataRole.DecorationRole: + if isinstance(device := node.payload, Device): + if icon := device.iconify_key: + return QIconifyIcon(icon, color="gray").pixmap(16, 16) + return QIcon.fromTheme("emblem-system") # pragma: no cover + elif isinstance(setting := node.payload, DevicePropertySetting): + if setting.is_read_only: + return QIcon.fromTheme("lock") + elif setting.is_pre_init: + return QIconifyIcon( + "ph:letter-circle-p-duotone", color="gray" + ).pixmap(16, 16) + elif setting.property_type == PropertyType.String: + return QIconifyIcon("mdi:code-string", color="gray").pixmap( + 16, 16 + ) + elif setting.property_type in ( + PropertyType.Integer, + PropertyType.Float, + ): + return QIconifyIcon("mdi:numbers", color="gray").pixmap(16, 16) + return + + # Qt.ItemDataRole.UserRole => return the original python object + if role == Qt.ItemDataRole.UserRole: + return node.payload + + if isinstance(device := node.payload, Device): + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): + return device.name + elif isinstance(setting := node.payload, DevicePropertySetting): + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): + return setting.property_name + elif role == Qt.ItemDataRole.FontRole: + if setting.is_read_only: + font = QFont() + font.setItalic(True) + return font + elif role == Qt.ItemDataRole.CheckStateRole: + if not (setting.is_read_only or setting.is_pre_init): + return node.check_state + elif role == Qt.ItemDataRole.ForegroundRole: + if setting.is_read_only or setting.is_pre_init: + return QBrush(Qt.GlobalColor.gray) + + 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 = 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]) + + +class FlatPropertyModel(QAbstractProxyModel): + """Presents every *leaf* of an arbitrary tree model as a top-level row.""" + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self._leaves: list[QPersistentModelIndex] = [] + + def index(self, row: int, column: int, parent: QModelIndex = ...) -> QModelIndex: + if not (sm := self.sourceModel()): + return QModelIndex() + return sm.index(row, column, parent) + + # -------------------------------------------------------------------------------- + # mandatory proxy plumbing + # -------------------------------------------------------------------------------- + def setSourceModel(self, source_model: QAbstractItemModel | None) -> None: + super().setSourceModel(source_model) + self._rebuild() + # keep list in sync with structural changes + source_model.rowsInserted.connect(self._rebuild) + source_model.rowsRemoved.connect(self._rebuild) + source_model.modelReset.connect(self._rebuild) + + # map source ↔ proxy ----------------------------------------------------- + def mapToSource(self, proxy_index: QModelIndex) -> QModelIndex: + return ( + QModelIndex(self._leaves[proxy_index.row()]) + if proxy_index.isValid() + else QModelIndex() + ) + + def mapFromSource(self, source_index: QModelIndex) -> QModelIndex: + try: + row = self._leaves.index(QPersistentModelIndex(source_index)) + return self.createIndex(row, source_index.column()) + except ValueError: + return QModelIndex() + + # shape ------------------------------------------------------------------ + def rowCount(self, _parent: QModelIndex = NULL_INDEX) -> int: + return len(self._leaves) + + def columnCount(self, parent: QModelIndex = NULL_INDEX) -> int: + if sm := self.sourceModel(): + return sm.columnCount(self.mapToSource(parent)) + return 0 + + # data, flags, setData simply delegate to the source -------------------- + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if sm := self.sourceModel(): + return sm.data(self.mapToSource(index), role) + return None + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + if sm := self.sourceModel(): + return sm.flags(self.mapToSource(index)) + return Qt.ItemFlag.NoItemFlags + + def setData( + self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole + ) -> bool: + if sm := self.sourceModel(): + return sm.setData(self.mapToSource(index), value, role) + return False + + # helpers ---------------------------------------------------------------- + def _rebuild(self) -> None: + """Cache every leaf `QModelIndex` of the tree.""" + if not (sm := self.sourceModel()): + return + self.beginResetModel() + self._leaves.clear() + + def walk(parent: QModelIndex) -> None: + rows = sm.rowCount(parent) + for r in range(rows): + idx = sm.index(r, 0, parent) + if sm.rowCount(idx): # branch + walk(idx) + else: # leaf + self._leaves.append(QPersistentModelIndex(idx)) + + walk(QModelIndex()) + self.endResetModel() diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index d392cda80..706cdc28f 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -2,13 +2,13 @@ from typing import TYPE_CHECKING, cast -from pymmcore_plus import DeviceProperty, DeviceType, Keyword -from qtpy.QtCore import QModelIndex, Qt, Signal +from pymmcore_plus import DeviceType +from qtpy.QtCore import QModelIndex, QSortFilterProxyModel, Qt, Signal from qtpy.QtWidgets import ( - QHBoxLayout, QListView, QSplitter, QToolBar, + QTreeView, QVBoxLayout, QWidget, ) @@ -16,10 +16,13 @@ from pymmcore_widgets._models import ( ConfigGroup, ConfigPreset, + Device, + DevicePropertySetting, QConfigGroupsModel, + QDevicePropertyModel, get_config_groups, + get_loaded_devices, ) -from pymmcore_widgets.device_properties import DevicePropertyTable if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -73,6 +76,7 @@ def update_from_core( """ if update_configs: self.setData(get_config_groups(core)) + self._prop_selector.setAvailableDevices(get_loaded_devices(core)) # if update_available: # self._props._update_device_buttons(core) # self._prop_tables.update_options_from_core(core) @@ -96,13 +100,10 @@ def __init__(self, parent: QWidget | None = None) -> None: # layout ------------------------------------------------------------ - top = QWidget(self) - top_layout = QHBoxLayout(top) - top_layout.setContentsMargins(0, 0, 0, 0) - top_layout.setSpacing(0) - top_layout.addWidget(self.group_list) - top_layout.addWidget(self.preset_list) - top_layout.addWidget(self._prop_selector) + top = QSplitter(Qt.Orientation.Horizontal, self) + top.addWidget(self.group_list) + top.addWidget(self.preset_list) + top.addWidget(self._prop_selector) main_splitter = QSplitter(Qt.Orientation.Horizontal, self) main_splitter.setHandleWidth(1) @@ -236,159 +237,50 @@ def _our_preset_changed_by_range( return preset -# class _PropSettings(QSplitter): -# """A wrapper for DevicePropertyTable for use in ConfigGroupsEditor.""" - -# valueChanged = Signal() - -# def __init__(self, parent: QWidget | None = None) -> None: -# super().__init__(Qt.Orientation.Vertical, parent) -# # 2D table with presets as columns and device properties as rows -# self._presets_table = ConfigPresetsTable(self) - -# # regular property table for editing all device properties -# self._prop_tables = DevicePropertyTable() -# self._prop_tables.valueChanged.connect(self.valueChanged) -# self._prop_tables.setRowsCheckable(True) - -# # toolbar with device type buttons -# self._action_group = QActionGroup(self) -# self._action_group.setExclusive(False) -# tb, self._action_group = self._create_device_buttons() - -# bot = QWidget() -# bl = QVBoxLayout(bot) -# bl.setContentsMargins(0, 0, 0, 0) -# bl.addWidget(tb) -# bl.addWidget(self._prop_tables) - -# self.addWidget(self._presets_table) -# self.addWidget(bot) - -# self._filter_properties() - -# def value(self) -> list[Setting]: -# """Return the current value of the property table.""" -# return self._prop_tables.value() - -# def setValue(self, value: list[Setting]) -> None: -# """Set the value of the property table.""" -# self._prop_tables.setValue(value) - -# def _create_device_buttons(self) -> tuple[QToolBar, QActionGroup]: -# tb = QToolBar() -# tb.setMovable(False) -# tb.setFloatable(False) -# tb.setIconSize(QSize(18, 18)) -# tb.setStyleSheet("QToolBar {background: none; border: none;}") -# tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) -# tb.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - -# # clear action group -# action_group = QActionGroup(self) -# action_group.setExclusive(False) - -# for dev_type, checked in { -# DeviceType.CameraDevice: False, -# DeviceType.ShutterDevice: True, -# DeviceType.StateDevice: True, -# DeviceType.StageDevice: False, -# DeviceType.XYStageDevice: False, -# DeviceType.SerialDevice: False, -# DeviceType.GenericDevice: False, -# DeviceType.AutoFocusDevice: False, -# DeviceType.ImageProcessorDevice: False, -# DeviceType.SignalIODevice: False, -# DeviceType.MagnifierDevice: False, -# DeviceType.SLMDevice: False, -# DeviceType.HubDevice: False, -# DeviceType.GalvoDevice: False, -# DeviceType.CoreDevice: False, -# }.items(): -# icon = QIconifyIcon(ICONS[dev_type], color="gray") -# if act := tb.addAction( -# icon, -# dev_type.name.replace("Device", ""), -# self._filter_properties, -# ): -# act.setCheckable(True) -# act.setChecked(checked) -# act.setData(dev_type) -# action_group.addAction(act) - -# return tb, action_group - -# def _filter_properties(self) -> None: -# include_devices = { -# action.data() -# for action in self._action_group.actions() -# if action.isChecked() -# } -# if not include_devices: -# # If no devices are selected, show all properties -# for row in range(self._prop_tables.rowCount()): -# self._prop_tables.hideRow(row) - -# else: -# self._prop_tables.filterDevices( -# include_pre_init=False, -# include_read_only=False, -# always_show_checked=True, -# include_devices=include_devices, -# predicate=_hide_state_state, -# ) - -# def _update_device_buttons(self, core: CMMCorePlus) -> None: -# for action in self._action_group.actions(): -# dev_type = cast("DeviceType", action.data()) -# for dev in core.getLoadedDevicesOfType(dev_type): -# writeable_props = ( -# ( -# not core.isPropertyPreInit(dev, prop) -# and not core.isPropertyReadOnly(dev, prop) -# ) -# for prop in core.getDevicePropertyNames(dev) -# ) -# if any(writeable_props): -# action.setVisible(True) -# break -# else: -# action.setVisible(False) - - -def _hide_state_state(prop: DeviceProperty) -> bool | None: - """Hide the State property for StateDevice (it duplicates state label).""" - if prop.deviceType() == DeviceType.StateDevice and prop.name == Keyword.State: - return False - return None +# TODO: Allow GUI control of parameters +class DeviceTypeFilter(QSortFilterProxyModel): + def __init__(self, allowed: set[DeviceType], parent: QWidget | None = None) -> None: + super().__init__(parent) + self.allowed = allowed # e.g. {"Camera", "Shutter"} + + def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool: + if sm := self.sourceModel(): + idx = sm.index(source_row, 0, source_parent) + if DeviceType.Any in self.allowed: + return True + data = idx.data(Qt.ItemDataRole.UserRole) + if isinstance(obj := data, Device): + return obj.type in self.allowed + elif isinstance(obj, DevicePropertySetting): + if obj.is_pre_init or obj.is_read_only: + return False + return True class DevicePropertySelector(QWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) - self.table = DevicePropertyTable(self, connect_core=False) - self.table.filterDevices( - include_pre_init=False, - include_read_only=False, - always_show_checked=True, - # predicate=_hide_state_state, - ) - self.table.setRowsCheckable(True) - # hide the 2nd column (prop value) - self.table.setColumnHidden(1, True) - # hide header - if hh := self.table.horizontalHeader(): - hh.setVisible(False) + tree = QTreeView(self) + + self._model = QDevicePropertyModel() + # flat_proxy = FlatPropertyModel() + # proxy = DeviceTypeFilter(allowed={DeviceType.Camera}, parent=self) + # proxy.setSourceModel(self._model) + # flat_proxy.setSourceModel(self._model) + tree.setModel(self._model) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.table) + layout.addWidget(tree) def clear(self) -> None: """Clear the current selection.""" - self.table.setValue([]) + # self.table.setValue([]) def setChecked(self, settings: Iterable[tuple[str, str, str]]) -> None: """Set the checked state of the properties based on the given settings.""" - self.table.setValue(settings) + # self.table.setValue(settings) + + def setAvailableDevices(self, devices: Iterable[Device]) -> None: + self._model.set_devices(devices) From 0d16a3f3259e46791fd905354cae88e263a3e97c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 3 Jul 2025 08:28:25 -0400 Subject: [PATCH 38/70] tmp models --- src/pymmcore_widgets/_models/_flat_chatgpt.py | 219 ++++++++++ src/pymmcore_widgets/_models/_flat_claude.py | 356 ++++++++++++++++ src/pymmcore_widgets/_models/_flat_gemini.py | 391 ++++++++++++++++++ .../_models/_py_config_model.py | 15 + 4 files changed, 981 insertions(+) create mode 100644 src/pymmcore_widgets/_models/_flat_chatgpt.py create mode 100644 src/pymmcore_widgets/_models/_flat_claude.py create mode 100644 src/pymmcore_widgets/_models/_flat_gemini.py diff --git a/src/pymmcore_widgets/_models/_flat_chatgpt.py b/src/pymmcore_widgets/_models/_flat_chatgpt.py new file mode 100644 index 000000000..4aae4a465 --- /dev/null +++ b/src/pymmcore_widgets/_models/_flat_chatgpt.py @@ -0,0 +1,219 @@ +""" +flatten_proxy_demo.py +Run with: python flatten_proxy_demo.py. +""" + +from __future__ import annotations + +from PyQt6 import QtCore, QtGui, QtWidgets + + +class TreeFlatteningProxyModel(QtCore.QAbstractProxyModel): + """ + Proxy that flattens a tree model so that every node at `row_depth` + appears as one row in a table. Column 0 shows that node itself, + column 1 its parent, column 2 its grand-parent, and so on up to the root. + """ + + def __init__( + self, row_depth: int = 0, parent: QtCore.QObject | None = None + ) -> None: + super().__init__(parent) + self._row_depth: int = row_depth + self._rows: list[QtCore.QModelIndex] = [] + + # ------------- public API ------------------------------------------------- + def set_row_depth(self, depth: int) -> None: + if depth != self._row_depth: + self._row_depth = depth + self._rebuild_row_map() + + def row_depth(self) -> int: + return self._row_depth + + # ------------- QAbstractProxyModel overrides ----------------------------- + def setSourceModel( # type: ignore[override] + self, source_model: QtCore.QAbstractItemModel | None + ) -> None: + super().setSourceModel(source_model) + self._rebuild_row_map() + + # ---- structure + def rowCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int: + return 0 if parent.isValid() else len(self._rows) + + def columnCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int: + if parent.isValid() or not self.sourceModel(): + return 0 + # columns = row_depth + 1 (node itself + ancestors) + return self._row_depth + 1 + + def index( + self, + row: int, + column: int, + parent: QtCore.QModelIndex = QtCore.QModelIndex(), + ) -> QtCore.QModelIndex: + if parent.isValid() or not self.hasIndex(row, column, parent): + return QtCore.QModelIndex() + src_index = self._rows[row] + return self.createIndex( + row, column, src_index + ) # internalPointer holds source index + + def parent(self, _: QtCore.QModelIndex) -> QtCore.QModelIndex: + return QtCore.QModelIndex() # flat table has no parents + + # ---- mapping + def mapToSource(self, proxy_index: QtCore.QModelIndex) -> QtCore.QModelIndex: + return ( + proxy_index.internalPointer() + if proxy_index.isValid() + else QtCore.QModelIndex() + ) + + def mapFromSource(self, source_index: QtCore.QModelIndex) -> QtCore.QModelIndex: + try: + row = self._rows.index(source_index) + return self.createIndex(row, 0, source_index) + except ValueError: + return QtCore.QModelIndex() + + # ---- data + def data( + self, index: QtCore.QModelIndex, role: int = QtCore.Qt.ItemDataRole.DisplayRole + ): + if not index.isValid() or role != QtCore.Qt.ItemDataRole.DisplayRole: + return None + + src = self.mapToSource(index) + # walk up the tree: column 0 -> node, column 1 -> parent, etc. + node = src + for _ in range(index.column()): + node = node.parent() + return self.sourceModel().data(node, role) # type: ignore[arg-type] + + def headerData( + self, + section: int, + orientation: QtCore.Qt.Orientation, + role: int = QtCore.Qt.ItemDataRole.DisplayRole, + ): + if ( + orientation == QtCore.Qt.Orientation.Horizontal + and role == QtCore.Qt.ItemDataRole.DisplayRole + ): + return f"Level {self._row_depth - section}" + return self.sourceModel().headerData(section, orientation, role) # type: ignore[arg-type] + + # ------------- internal helpers ------------------------------------------ + def _rebuild_row_map(self) -> None: + self.beginResetModel() + self._rows.clear() + if src := self.sourceModel(): + self._collect_rows(QtCore.QModelIndex(), 0, src) + self.endResetModel() + + def _collect_rows( + self, + parent: QtCore.QModelIndex, + depth: int, + model: QtCore.QAbstractItemModel, + ) -> None: + if depth == self._row_depth: + for r in range(model.rowCount(parent)): + self._rows.append(model.index(r, 0, parent)) + return + for r in range(model.rowCount(parent)): + child = model.index(r, 0, parent) + self._collect_rows(child, depth + 1, model) + + +# ----------------------------------------------------------------------------- +# demo helpers +# ----------------------------------------------------------------------------- +def build_tree_model() -> QtGui.QStandardItemModel: + """Create a simple 5-level tree: A-i / B-j / C-k / D-l / E-m.""" + model = QtGui.QStandardItemModel() + model.setHorizontalHeaderLabels(["Name"]) + for ai in range(2): # A0, A1 + item_a = QtGui.QStandardItem(f"A{ai}") + for bj in range(2): + item_b = QtGui.QStandardItem(f"B{ai}{bj}") + for ck in range(2): + item_c = QtGui.QStandardItem(f"C{ai}{bj}{ck}") + for dl in range(2): + item_d = QtGui.QStandardItem(f"D{ai}{bj}{ck}{dl}") + for em in range(2): + item_e = QtGui.QStandardItem(f"E{ai}{bj}{ck}{dl}{em}") + item_d.appendRow(item_e) + item_c.appendRow(item_d) + item_b.appendRow(item_c) + item_a.appendRow(item_b) + model.appendRow(item_a) + return model + + +class MainWindow(QtWidgets.QWidget): + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("Flatten proxy demo") + + # source tree + src_model = build_tree_model() + tree = QtWidgets.QTreeView() + tree.setModel(src_model) + tree.setHeaderHidden(False) + tree.expandAll() + + # proxy + table view + self.proxy = TreeFlatteningProxyModel(row_depth=4) + self.proxy.setSourceModel(src_model) + + sort_filter = QtCore.QSortFilterProxyModel() + sort_filter.setSourceModel(self.proxy) + sort_filter.setDynamicSortFilter(True) + + table = QtWidgets.QTableView() + table.setModel(sort_filter) + table.setSortingEnabled(True) + table.horizontalHeader().setStretchLastSection(True) + + # depth selector + depth_selector = QtWidgets.QComboBox() + depth_selector.addItems( + [ + "Rows = level E (depth 4)", + "Rows = level D (depth 3)", + "Rows = level C (depth 2)", + "Rows = level B (depth 1)", + ] + ) + depth_selector.setCurrentIndex(0) + + depth_selector.currentIndexChanged.connect( + lambda i: self.proxy.set_row_depth(4 - i) + ) + + # layout + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(QtWidgets.QLabel("Source tree")) + layout.addWidget(tree, 2) + layout.addWidget(QtWidgets.QLabel("Flattened table (sortable)")) + layout.addWidget(table, 3) + layout.addWidget(QtWidgets.QLabel("Choose row depth")) + layout.addWidget(depth_selector) + + +def main() -> None: + import sys + + app = QtWidgets.QApplication(sys.argv) + win = MainWindow() + win.resize(800, 600) + win.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/src/pymmcore_widgets/_models/_flat_claude.py b/src/pymmcore_widgets/_models/_flat_claude.py new file mode 100644 index 000000000..1406b0385 --- /dev/null +++ b/src/pymmcore_widgets/_models/_flat_claude.py @@ -0,0 +1,356 @@ +import sys +from enum import Enum +from typing import NamedTuple + +from PyQt6.QtCore import QAbstractProxyModel, QModelIndex, Qt +from PyQt6.QtGui import QStandardItem, QStandardItemModel +from PyQt6.QtWidgets import ( + QApplication, + QHBoxLayout, + QMainWindow, + QPushButton, + QTableView, + QTreeView, + QVBoxLayout, + QWidget, +) + + +class FlattenMode(Enum): + FLATTEN_TO_LEVEL = "flatten_to_level" + FLATTEN_WITH_EXPANSION = "flatten_with_expansion" + + +class FlattenedItem(NamedTuple): + source_index: QModelIndex + path: list[QModelIndex] + is_expanded: bool + display_level: int + + +class TreeFlatteningProxyModel(QAbstractProxyModel): + """ + A proxy model that flattens a tree structure to show specified levels + as rows in a table format. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._flattened_items: list[FlattenedItem] = [] + self._flatten_depth = 0 + self._mode = FlattenMode.FLATTEN_TO_LEVEL + self._secondary_depth = -1 # For expandable mode + + def setSourceModel(self, model): + """Set the source model and rebuild the flattened structure.""" + if self.sourceModel(): + self.sourceModel().dataChanged.disconnect(self._on_source_data_changed) + self.sourceModel().rowsInserted.disconnect(self._on_source_rows_inserted) + self.sourceModel().rowsRemoved.disconnect(self._on_source_rows_removed) + self.sourceModel().modelReset.disconnect(self._on_source_model_reset) + + super().setSourceModel(model) + + if model: + model.dataChanged.connect(self._on_source_data_changed) + model.rowsInserted.connect(self._on_source_rows_inserted) + model.rowsRemoved.connect(self._on_source_rows_removed) + model.modelReset.connect(self._on_source_model_reset) + + self._rebuild_flattened_structure() + + def set_flatten_depth(self, depth: int): + """Set the depth level to flatten to.""" + self._flatten_depth = depth + self._rebuild_flattened_structure() + + def set_flatten_mode(self, mode: FlattenMode): + """Set the flattening mode.""" + self._mode = mode + self._rebuild_flattened_structure() + + def set_flatten_configuration(self, primary_level: int, secondary_level: int = -1): + """Set both primary and secondary levels for complex flattening.""" + self._flatten_depth = primary_level + self._secondary_depth = secondary_level + self._rebuild_flattened_structure() + + def _rebuild_flattened_structure(self): + """Rebuild the entire flattened structure.""" + self.beginResetModel() + self._flattened_items.clear() + + if not self.sourceModel(): + self.endResetModel() + return + + self._build_flattened_items(QModelIndex(), [], 0) + self.endResetModel() + + def _build_flattened_items( + self, parent: QModelIndex, current_path: list[QModelIndex], current_depth: int + ): + """Recursively build the flattened items structure.""" + if not self.sourceModel(): + return + + row_count = self.sourceModel().rowCount(parent) + + for i in range(row_count): + child = self.sourceModel().index(i, 0, parent) + if not child.isValid(): + continue + + child_path = [*current_path, child] + + # Check if this is our target level + if current_depth == self._flatten_depth: + item = FlattenedItem( + source_index=child, + path=child_path, + is_expanded=False, + display_level=current_depth, + ) + self._flattened_items.append(item) + + # For expandable mode, also add intermediate levels + elif ( + self._mode == FlattenMode.FLATTEN_WITH_EXPANSION + and self._secondary_depth != -1 + and current_depth == self._secondary_depth + ): + item = FlattenedItem( + source_index=child, + path=child_path, + is_expanded=False, + display_level=current_depth, + ) + self._flattened_items.append(item) + + # Continue recursion if there are children + if self.sourceModel().hasChildren(child): + self._build_flattened_items(child, child_path, current_depth + 1) + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + """Return the number of flattened items.""" + if parent.isValid(): + return 0 # Flat structure, no children + return len(self._flattened_items) + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: + """Return the number of columns (levels in the path).""" + if not self._flattened_items: + return 0 + return self._flatten_depth + 1 + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): + """Return data for the given index.""" + if not index.isValid() or index.row() >= len(self._flattened_items): + return None + + item = self._flattened_items[index.row()] + + # Map columns to different levels of the path + if index.column() < len(item.path): + source_idx = item.path[index.column()] + return self.sourceModel().data(source_idx, role) + + return None + + def headerData( + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ): + """Return header data.""" + if ( + role == Qt.ItemDataRole.DisplayRole + and orientation == Qt.Orientation.Horizontal + ): + return f"Level {section}" + return None + + def index( + self, row: int, column: int, parent: QModelIndex = QModelIndex() + ) -> QModelIndex: + """Create an index for the given row and column.""" + if ( + parent.isValid() + or row < 0 + or row >= len(self._flattened_items) + or column < 0 + or column >= self.columnCount() + ): + return QModelIndex() + + return self.createIndex(row, column, row) # Use row as internal pointer + + def parent(self, index: QModelIndex) -> QModelIndex: + """Return parent index (always invalid for flat structure).""" + return QModelIndex() + + def mapToSource(self, proxy_index: QModelIndex) -> QModelIndex: + """Map proxy index to source index.""" + if not proxy_index.isValid() or proxy_index.row() >= len(self._flattened_items): + return QModelIndex() + + item = self._flattened_items[proxy_index.row()] + if proxy_index.column() < len(item.path): + return item.path[proxy_index.column()] + + return QModelIndex() + + def mapFromSource(self, source_index: QModelIndex) -> QModelIndex: + """Map source index to proxy index.""" + if not source_index.isValid(): + return QModelIndex() + + # Find the flattened item that contains this source index + for row, item in enumerate(self._flattened_items): + if source_index in item.path: + col = item.path.index(source_index) + return self.index(row, col) + + return QModelIndex() + + def toggle_expansion(self, proxy_index: QModelIndex): + """Toggle expansion state for expandable items.""" + if not proxy_index.isValid() or proxy_index.row() >= len(self._flattened_items): + return + + # This is where you'd implement expansion logic + # For now, just rebuild - in a real implementation you'd be more selective + self._rebuild_flattened_structure() + + # Source model change handlers + def _on_source_data_changed( + self, top_left: QModelIndex, bottom_right: QModelIndex, roles: list[int] + ): + """Handle source model data changes.""" + # Simple approach: rebuild everything + # In a real implementation, you'd be more selective + self._rebuild_flattened_structure() + + def _on_source_rows_inserted(self, parent: QModelIndex, first: int, last: int): + """Handle source model row insertion.""" + self._rebuild_flattened_structure() + + def _on_source_rows_removed(self, parent: QModelIndex, first: int, last: int): + """Handle source model row removal.""" + self._rebuild_flattened_structure() + + def _on_source_model_reset(self): + """Handle source model reset.""" + self._rebuild_flattened_structure() + + +if __name__ == "__main__": + app = QApplication(sys.argv) + + class TreeFlatteningDemo(QMainWindow): + """Demo application showing the tree flattening proxy model in action.""" + + def __init__(self): + super().__init__() + self.setWindowTitle("Tree Flattening Proxy Model Demo") + self.setGeometry(100, 100, 800, 600) + + # Create the source model with sample data + self.source_model = self._create_sample_model() + + # Create the proxy model + self.proxy_model = TreeFlatteningProxyModel() + self.proxy_model.setSourceModel(self.source_model) + + self._setup_ui() + + # Set initial configuration + self.proxy_model.set_flatten_depth(4) # Show all E's + + def _create_sample_model(self) -> QStandardItemModel: + """Create a sample tree model with A>B>C>D>E structure.""" + model = QStandardItemModel() + model.setHorizontalHeaderLabels(["Name"]) + + # Create sample data: A > B > C > D > E + for a_idx in range(2): + item_a = QStandardItem(f"A{a_idx + 1}") + model.appendRow(item_a) + + for b_idx in range(3): + item_b = QStandardItem(f"B{b_idx + 1}") + item_a.appendRow(item_b) + + for c_idx in range(2): + item_c = QStandardItem(f"C{c_idx + 1}") + item_b.appendRow(item_c) + + for d_idx in range(2): + item_d = QStandardItem(f"D{d_idx + 1}") + item_c.appendRow(item_d) + + for e_idx in range(3): + item_e = QStandardItem(f"E{e_idx + 1}") + item_d.appendRow(item_e) + + return model + + def _setup_ui(self): + """Setup the user interface.""" + central_widget = QWidget() + self.setCentralWidget(central_widget) + layout = QVBoxLayout(central_widget) + + # Control buttons + button_layout = QHBoxLayout() + + btn_show_all_e = QPushButton("Show All E's") + btn_show_all_e.clicked.connect( + lambda: self.proxy_model.set_flatten_depth(4) + ) + + btn_show_all_d = QPushButton("Show All D's") + btn_show_all_d.clicked.connect( + lambda: self.proxy_model.set_flatten_depth(3) + ) + + btn_show_all_c = QPushButton("Show All C's") + btn_show_all_c.clicked.connect( + lambda: self.proxy_model.set_flatten_depth(2) + ) + + btn_show_all_b = QPushButton("Show All B's") + btn_show_all_b.clicked.connect( + lambda: self.proxy_model.set_flatten_depth(1) + ) + + button_layout.addWidget(btn_show_all_e) + button_layout.addWidget(btn_show_all_d) + button_layout.addWidget(btn_show_all_c) + button_layout.addWidget(btn_show_all_b) + + layout.addLayout(button_layout) + + # Views layout + views_layout = QHBoxLayout() + + # Original tree view + self.tree_view = QTreeView() + self.tree_view.setModel(self.source_model) + self.tree_view.expandAll() + + # Flattened table view + self.table_view = QTableView() + self.table_view.setModel(self.proxy_model) + self.table_view.setSortingEnabled(True) + + views_layout.addWidget(self.tree_view) + views_layout.addWidget(self.table_view) + + layout.addLayout(views_layout) + + demo = TreeFlatteningDemo() + demo.show() + + sys.exit(app.exec()) diff --git a/src/pymmcore_widgets/_models/_flat_gemini.py b/src/pymmcore_widgets/_models/_flat_gemini.py new file mode 100644 index 000000000..d64336f58 --- /dev/null +++ b/src/pymmcore_widgets/_models/_flat_gemini.py @@ -0,0 +1,391 @@ +import sys + +from PyQt6.QtCore import ( + QAbstractProxyModel, + QModelIndex, + QPersistentModelIndex, + QSortFilterProxyModel, + Qt, +) +from PyQt6.QtGui import QStandardItem, QStandardItemModel +from PyQt6.QtWidgets import ( + QApplication, + QComboBox, + QHeaderView, + QLabel, + QLineEdit, + QMainWindow, + QTreeView, + QVBoxLayout, + QWidget, +) + + +class TreeFlatteningProxyModel(QAbstractProxyModel): + """ + A proxy model that can flatten a source tree model to an arbitrary depth + or present a mixed hierarchy. + + - Level -1 (Default): Pass-through, acts like a normal tree. + - Level 0: Flatten at level 'A'. Each row is an 'A' item. + - Level 1: Flatten at level 'B'. Each row is a 'B' item. + ...and so on. + + When a level is chosen for flattening, all ancestor data is presented + in columns. For levels below the flattening level, the model can + expose the hierarchy, allowing a QTreeView to expand/collapse children. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._flattening_level = -1 + # _source_map will store QPersistentModelIndex objects of the source items + # that should be treated as top-level rows in the proxy. + self._source_map = [] + + def setFlatteningLevel(self, level): + """ + Sets the depth to which the source model is flattened. + -1 means no flattening (pass-through). + 0 means flatten at the root level, 1 at the next, etc. + """ + if self._flattening_level == level: + return + + self.beginResetModel() + self._flattening_level = level + self._source_map.clear() + + if self._flattening_level > -1: + # Build the map of source indexes that will become our top-level rows. + self._build_map_recursive(self.sourceModel().invisibleRootItem().index(), 0) + + self.endResetModel() + + def _build_map_recursive(self, parent_source_index, depth): + """ + Recursively traverses the source model to find all items + at the target flattening level. + """ + source_model = self.sourceModel() + for row in range(source_model.rowCount(parent_source_index)): + child_source_index = source_model.index(row, 0, parent_source_index) + if not child_source_index.isValid(): + continue + + if depth == self._flattening_level: + # We found an item at the target level. Add it to our map. + # Use QPersistentModelIndex because the source model could change. + self._source_map.append(QPersistentModelIndex(child_source_index)) + elif depth < self._flattening_level: + # We haven't reached the target depth yet, so go deeper. + self._build_map_recursive(child_source_index, depth + 1) + + def mapToSource(self, proxy_index): + """Maps a proxy index back to its corresponding source index.""" + if not proxy_index.isValid() or self.sourceModel() is None: + return QModelIndex() + + # Pass-through mode + if self._flattening_level == -1: + return self.sourceModel().index( + proxy_index.row(), + proxy_index.column(), + self.mapToSource(proxy_index.parent()), + ) + + # The internal pointer of a proxy index is our secret weapon. + # We use it to store the original source index for child items in a mixed hierarchy. + source_item_index = proxy_index.internalPointer() + + if source_item_index and source_item_index.isValid(): + # This is a child of a flattened item (e.g., a 'C' under a 'B' row). + # The pointer IS the source index. + return source_item_index + + # This is a top-level flattened item. We need to calculate the source index. + if 0 <= proxy_index.row() < len(self._source_map): + base_source_index = self._source_map[proxy_index.row()] + + # Traverse up the tree from the base source index to find the + # correct ancestor for the requested column. + source_index = base_source_index + for _ in range(self._flattening_level - proxy_index.column()): + source_index = source_index.parent() + return source_index + + return QModelIndex() + + def mapFromSource(self, source_index): + """Maps a source index to its corresponding proxy index.""" + if not source_index.isValid() or self.sourceModel() is None: + return QModelIndex() + + # Pass-through mode + if self._flattening_level == -1: + source_parent = source_index.parent() + proxy_parent = self.mapFromSource(source_parent) + return self.index(source_index.row(), source_index.column(), proxy_parent) + + # Find the item's ancestor that is at the flattening level. + ancestor_at_flattening_level = source_index + while ( + ancestor_at_flattening_level.isValid() + and ancestor_at_flattening_level.internalId() > 0 + ): # a bit of a heuristic check + parent = ancestor_at_flattening_level.parent() + if ( + not parent.isValid() + or parent == self.sourceModel().invisibleRootItem().index() + ): + break # Reached the top + if ( + len(self.sourceModel().data(parent, Qt.ItemDataRole.DisplayRole)) == 1 + ): # Heuristic for depth + break + ancestor_at_flattening_level = parent + + try: + # Find which row this ancestor corresponds to in our map. + proxy_row = self._source_map.index( + QPersistentModelIndex(ancestor_at_flattening_level) + ) + except ValueError: + return QModelIndex() # Not found in our map. + + # Now determine if this is a top-level item or a child. + if ancestor_at_flattening_level == source_index: + # It's a top-level item. Column is its depth. + return self.createIndex(proxy_row, source_index.column(), None) + else: + # It's a child of a top-level item. + return self.createIndex( + source_index.row(), source_index.column(), source_index + ) + + def rowCount(self, parent_proxy_index=QModelIndex()): + """Returns the number of rows under the given parent.""" + if self.sourceModel() is None: + return 0 + + # Pass-through mode + if self._flattening_level == -1: + return self.sourceModel().rowCount(self.mapToSource(parent_proxy_index)) + + if not parent_proxy_index.isValid(): + # Requesting number of top-level items. + return len(self._source_map) + + # Requesting number of children for an expanded item. + parent_source_index = self.mapToSource(parent_proxy_index) + return self.sourceModel().rowCount(parent_source_index) + + def columnCount(self, parent_proxy_index=QModelIndex()): + """Returns the number of columns.""" + if self.sourceModel() is None: + return 0 + + # Pass-through mode + if self._flattening_level == -1: + return self.sourceModel().columnCount(self.mapToSource(parent_proxy_index)) + + # We show one column for each level down to the flattening level. + return self._flattening_level + 1 + + def parent(self, proxy_child_index): + """Returns the parent of the given proxy index.""" + if not proxy_child_index.isValid() or self._flattening_level == -1: + return QModelIndex() + + source_child_index = self.mapToSource(proxy_child_index) + if not source_child_index.isValid(): + return QModelIndex() + + source_parent_index = source_child_index.parent() + + # Check if the source parent is one of our top-level items. + try: + # Find the row in our map that corresponds to the source parent. + proxy_row = self._source_map.index( + QPersistentModelIndex(source_parent_index) + ) + # If found, create a proxy index for it. This is the parent. + return self.createIndex(proxy_row, 0, None) # Parent is a top-level item + except ValueError: + # The parent is not a top-level item, so this child has no visible parent in the proxy. + return QModelIndex() + + def index(self, row, column, parent_proxy_index=QModelIndex()): + """Creates a proxy index for the given row, column, and parent.""" + if not self.hasIndex(row, column, parent_proxy_index): + return QModelIndex() + + # Pass-through mode + if self._flattening_level == -1: + source_parent_index = self.mapToSource(parent_proxy_index) + source_child_index = self.sourceModel().index( + row, column, source_parent_index + ) + return self.mapFromSource(source_child_index) + + if not parent_proxy_index.isValid(): + # Creating an index for a top-level item. + # We don't need to store anything in the internal pointer for these. + return self.createIndex(row, column, None) + else: + # Creating an index for a child of an expanded item. + # We store the child's *source model index* in the internal pointer. + # This is the key to linking back correctly in mapToSource. + parent_source_index = self.mapToSource(parent_proxy_index) + child_source_index = self.sourceModel().index(row, 0, parent_source_index) + return self.createIndex(row, column, child_source_index) + + def data(self, proxy_index, role=Qt.ItemDataRole.DisplayRole): + """Returns the data for a given proxy index.""" + if not proxy_index.isValid() or self.sourceModel() is None: + return None + + source_index = self.mapToSource(proxy_index) + if source_index.isValid(): + return self.sourceModel().data(source_index, role) + return None + + def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): + """Returns the header data.""" + if ( + orientation == Qt.Orientation.Horizontal + and role == Qt.ItemDataRole.DisplayRole + ): + if self._flattening_level == -1: + return self.sourceModel().headerData(section, orientation, role) + + if 0 <= section <= self._flattening_level: + # Create headers like 'A', 'B', 'C'... + return chr(ord("A") + section) + return super().headerData(section, orientation, role) + + +if __name__ == "__main__": + + class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Dynamic Flattening Proxy Model Demo") + self.setGeometry(100, 100, 1000, 700) + + # Main widget and layout + central_widget = QWidget() + self.setCentralWidget(central_widget) + layout = QVBoxLayout(central_widget) + + # 1. Create the source model + self.source_model = self._create_source_model() + + # 2. Create our custom flattening proxy + self.flattening_proxy = TreeFlatteningProxyModel() + self.flattening_proxy.setSourceModel(self.source_model) + + # 3. Create a standard sort/filter proxy to chain them + self.sort_filter_proxy = QSortFilterProxyModel() + # IMPORTANT: The source for the sort/filter proxy is our custom proxy + self.sort_filter_proxy.setSourceModel(self.flattening_proxy) + self.sort_filter_proxy.setFilterCaseSensitivity( + Qt.CaseSensitivity.CaseInsensitive + ) + self.sort_filter_proxy.setFilterKeyColumn(-1) # Filter on all columns + + # 4. Create the view + self.view = QTreeView() + # IMPORTANT: The view's model is the final proxy in the chain + self.view.setModel(self.sort_filter_proxy) + self.view.setSortingEnabled(True) + self.view.setAlternatingRowColors(True) + self.view.header().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + + # 5. Create controls + controls_layout = QVBoxLayout() + + # Control for filtering + filter_label = QLabel("Filter Table/Tree:") + self.filter_input = QLineEdit() + self.filter_input.textChanged.connect( + self.sort_filter_proxy.setFilterRegularExpression + ) + + # Control for changing flattening level + level_label = QLabel("Select Flattening Level:") + self.level_combo = QComboBox() + self.level_combo.addItems( + [ + "Disabled (Standard Tree View)", + "Level 0 (Rows of A)", + "Level 1 (Rows of B)", + "Level 2 (Rows of C)", + "Level 3 (Rows of D)", + "Level 4 (Rows of E - Full Flatten)", + ] + ) + self.level_combo.currentIndexChanged.connect(self.level_changed) + + controls_layout.addWidget(filter_label) + controls_layout.addWidget(self.filter_input) + controls_layout.addWidget(level_label) + controls_layout.addWidget(self.level_combo) + + layout.addLayout(controls_layout) + layout.addWidget(self.view) + + # Initialize view + self.level_changed(0) + + def _create_source_model(self): + """Creates and populates a 5-level deep QStandardItemModel.""" + model = QStandardItemModel() + model.setHorizontalHeaderLabels( + ["Data"] + ) # Only one column in the source model + + root = model.invisibleRootItem() + for i in range(3): # A + item_a = QStandardItem(f"A{i + 1}") + root.appendRow(item_a) + for j in range(2): # B + item_b = QStandardItem(f"B{i * 2 + j + 1}") + item_a.appendRow(item_b) + for k in range(2): # C + item_c = QStandardItem(f"C{i * 4 + j * 2 + k + 1}") + item_b.appendRow(item_c) + # Make D and E levels a bit irregular + for l in range(1 + (j % 2)): # D + item_d = QStandardItem(f"D{i * 8 + j * 4 + k * 2 + l + 1}") + item_c.appendRow(item_d) + for m in range(2 + (k % 2)): # E + item_e = QStandardItem( + f"E{i * 16 + j * 8 + k * 4 + l * 2 + m + 1}" + ) + item_d.appendRow(item_e) + return model + + def level_changed(self, index): + """Slot to handle the user changing the flattening level.""" + level = index - 1 + self.flattening_proxy.setFlatteningLevel(level) + + # Adjust view properties for better user experience + if level == -1: + # Standard tree view + self.view.header().setVisible(False) + self.view.expandAll() + else: + # Table-like view + self.view.header().setVisible(True) + self.view.expandAll() # Expand to see children in mixed-hierarchy views + self.view.header().setSectionResizeMode( + QHeaderView.ResizeMode.ResizeToContents + ) + self.view.header().setStretchLastSection(True) + + app = QApplication(sys.argv) + window = MainWindow() + window.show() + sys.exit(app.exec()) diff --git a/src/pymmcore_widgets/_models/_py_config_model.py b/src/pymmcore_widgets/_models/_py_config_model.py index 1c8fc3be3..640e4d519 100644 --- a/src/pymmcore_widgets/_models/_py_config_model.py +++ b/src/pymmcore_widgets/_models/_py_config_model.py @@ -33,6 +33,11 @@ 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.""" @@ -135,6 +140,11 @@ def __eq__(self, value: object) -> bool: 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) + class ConfigGroup(_BaseModel): """A group of ConfigPresets.""" @@ -142,6 +152,11 @@ class ConfigGroup(_BaseModel): name: str presets: dict[str, ConfigPreset] = Field(default_factory=dict) + @property + def children(self) -> tuple[ConfigPreset, ...]: + """Return the presets in the group.""" + return tuple(self.presets.values()) + class PixelSizePreset(ConfigPreset): """PixelSizePreset model.""" From 1ea69946ea1d0cbf466c9668b5968db21330f48d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 3 Jul 2025 10:53:36 -0400 Subject: [PATCH 39/70] tmp models --- src/pymmcore_widgets/_models/_flat_chatgpt.py | 803 +++++++++++++----- 1 file changed, 606 insertions(+), 197 deletions(-) diff --git a/src/pymmcore_widgets/_models/_flat_chatgpt.py b/src/pymmcore_widgets/_models/_flat_chatgpt.py index 4aae4a465..4a703b2f6 100644 --- a/src/pymmcore_widgets/_models/_flat_chatgpt.py +++ b/src/pymmcore_widgets/_models/_flat_chatgpt.py @@ -1,219 +1,628 @@ -""" -flatten_proxy_demo.py -Run with: python flatten_proxy_demo.py. -""" - from __future__ import annotations -from PyQt6 import QtCore, QtGui, QtWidgets +import sys +from typing import NamedTuple + +from qtpy import QtCore, QtWidgets +from qtpy.QtCore import QAbstractProxyModel, QModelIndex, QPersistentModelIndex, Qt +from qtpy.QtGui import QStandardItem, QStandardItemModel +from qtpy.QtWidgets import ( + QApplication, + QComboBox, + QHBoxLayout, + QHeaderView, + QLabel, + QTreeView, + QVBoxLayout, + QWidget, +) + +class RowInfo(NamedTuple): + leaf: QPersistentModelIndex + ancestors: list[QPersistentModelIndex] -class TreeFlatteningProxyModel(QtCore.QAbstractProxyModel): - """ - Proxy that flattens a tree model so that every node at `row_depth` - appears as one row in a table. Column 0 shows that node itself, - column 1 its parent, column 2 its grand-parent, and so on up to the root. - """ - def __init__( - self, row_depth: int = 0, parent: QtCore.QObject | None = None - ) -> None: +class FlattenProxyModel(QAbstractProxyModel): + def __init__(self, level: int = 0, parent: QtCore.QObject | None = None): super().__init__(parent) - self._row_depth: int = row_depth - self._rows: list[QtCore.QModelIndex] = [] - - # ------------- public API ------------------------------------------------- - def set_row_depth(self, depth: int) -> None: - if depth != self._row_depth: - self._row_depth = depth - self._rebuild_row_map() - - def row_depth(self) -> int: - return self._row_depth - - # ------------- QAbstractProxyModel overrides ----------------------------- - def setSourceModel( # type: ignore[override] - self, source_model: QtCore.QAbstractItemModel | None - ) -> None: - super().setSourceModel(source_model) - self._rebuild_row_map() - - # ---- structure - def rowCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int: - return 0 if parent.isValid() else len(self._rows) - - def columnCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int: - if parent.isValid() or not self.sourceModel(): + self._level = level + self._rows: list[RowInfo] = [] + self._src2row: dict[QPersistentModelIndex, int] = {} + self._mixed = False # Whether to show mixed hierarchy + self._child_cache: dict[int, QPersistentModelIndex] = {} # Cache for child indices + + def setLevel(self, level: int): + self._level = level + self._rebuild() + + def setMixed(self, mixed: bool): + self._mixed = mixed + self._rebuild() + + def _rebuild(self): + self.beginResetModel() + self._rows.clear() + self._src2row.clear() + self._child_cache.clear() + self._child_cache.clear() + + if not self.sourceModel(): + self.endResetModel() + return + + if self._level < 0: + # Pass-through mode + self.endResetModel() + return + + # Build flattened rows + self._traverse(QModelIndex(), []) + self.endResetModel() + + def _traverse(self, parent: QModelIndex, ancestors: list[QPersistentModelIndex]): + for r in range(self.sourceModel().rowCount(parent)): + child = self.sourceModel().index(r, 0, parent) + child_ancestors = [*ancestors, QPersistentModelIndex(child)] + + if len(child_ancestors) - 1 == self._level: + # This is a leaf at the desired level + row_info = RowInfo(QPersistentModelIndex(child), child_ancestors) + self._src2row[QPersistentModelIndex(child)] = len(self._rows) + self._rows.append(row_info) + elif len(child_ancestors) - 1 < self._level: + # Keep going deeper + self._traverse(child, child_ancestors) + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + if not self.sourceModel(): + return 0 + + if self._level < 0: + # Pass-through mode + return self.sourceModel().rowCount(self.mapToSource(parent)) + + if not parent.isValid(): + # Top-level: return number of flattened rows + return len(self._rows) + + # Mixed hierarchy: children of flattened rows + if self._mixed and not parent.internalPointer(): + # Children of a flattened row + try: + rowinfo = self._rows[parent.row()] + src_parent = QModelIndex(rowinfo.leaf) + return self.sourceModel().rowCount(src_parent) + except: + return 0 + + # Grandchildren or deeper + if self._mixed and parent.internalPointer(): + try: + parent_id = parent.internalPointer() + if isinstance(parent_id, int) and parent_id in self._child_cache: + src_parent_persistent = self._child_cache[parent_id] + if src_parent_persistent.isValid(): + # Create new QModelIndex from QPersistentModelIndex + src_parent = self.sourceModel().index( + src_parent_persistent.row(), + src_parent_persistent.column(), + QModelIndex() # Simplified for now + ) + return self.sourceModel().rowCount(src_parent) + except: + pass return 0 - # columns = row_depth + 1 (node itself + ancestors) - return self._row_depth + 1 + + return 0 + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: + if not self.sourceModel(): + return 0 + if self._level < 0: + # Pass-through mode + return self.sourceModel().columnCount() + # In flattened mode, show columns for each level in the hierarchy + return self._level + 1 + + def hasChildren(self, parent: QModelIndex = QModelIndex()) -> bool: + if not self.sourceModel(): + return False + + if self._level < 0: + # Pass-through mode + src_index = self.mapToSource(parent) + return self.sourceModel().hasChildren(src_index) + + if not parent.isValid(): + # Top-level always has children (the flattened rows) + return len(self._rows) > 0 + + # Mixed hierarchy: check if flattened rows have children + if self._mixed and not parent.internalPointer(): + try: + if parent.row() < len(self._rows): + rowinfo = self._rows[parent.row()] + # Recreate QModelIndex from QPersistentModelIndex using its data + if rowinfo.leaf.isValid(): + # Get the parent of the leaf to recreate the hierarchy + persistent_parent = rowinfo.leaf.parent() if rowinfo.leaf.parent().isValid() else QModelIndex() + src_index = self.sourceModel().index( + rowinfo.leaf.row(), + rowinfo.leaf.column(), + persistent_parent + ) + return self.sourceModel().hasChildren(src_index) + except Exception: + pass + return False + + # Grandchildren or deeper + if self._mixed and parent.internalPointer(): + try: + parent_id = parent.internalPointer() + if isinstance(parent_id, int) and parent_id in self._child_cache: + src_parent_persistent = self._child_cache[parent_id] + if src_parent_persistent.isValid(): + # Create new QModelIndex from QPersistentModelIndex + src_parent = self.sourceModel().index( + src_parent_persistent.row(), + src_parent_persistent.column(), + QModelIndex() # Simplified for now + ) + return self.sourceModel().hasChildren(src_parent) + except: + pass + return False + + return False def index( - self, - row: int, - column: int, - parent: QtCore.QModelIndex = QtCore.QModelIndex(), - ) -> QtCore.QModelIndex: - if parent.isValid() or not self.hasIndex(row, column, parent): - return QtCore.QModelIndex() - src_index = self._rows[row] - return self.createIndex( - row, column, src_index - ) # internalPointer holds source index - - def parent(self, _: QtCore.QModelIndex) -> QtCore.QModelIndex: - return QtCore.QModelIndex() # flat table has no parents - - # ---- mapping - def mapToSource(self, proxy_index: QtCore.QModelIndex) -> QtCore.QModelIndex: - return ( - proxy_index.internalPointer() - if proxy_index.isValid() - else QtCore.QModelIndex() - ) - - def mapFromSource(self, source_index: QtCore.QModelIndex) -> QtCore.QModelIndex: + self, row: int, col: int, parent: QModelIndex = QModelIndex() + ) -> QModelIndex: + # print(f"index(row={row}, col={col}, parent={parent}, parent.internalPointer={parent.internalPointer() if parent.isValid() else None})") + if row < 0 or col < 0: + # print(" index: negative row/col") + return QModelIndex() + + if not self.sourceModel(): + # print(" index: no sourceModel") + return QModelIndex() + + # Pass-through mode + if self._level < 0: + try: + src_parent = self.mapToSource(parent) + if not src_parent.isValid() and parent.isValid(): + # print(" index: passthrough, invalid src_parent") + return QModelIndex() + # print(" index: passthrough, createIndex with None") + return self.createIndex(row, col, None) + except Exception: + # print(f" index: passthrough exception: {e}") + return QModelIndex() + + # Top-level flattened row + if not parent.isValid(): + if row >= len(self._rows) or col >= self.columnCount(): + # print(" index: top-level out of bounds") + return QModelIndex() + # print(" index: top-level createIndex with None") + return self.createIndex(row, col, None) + + # Mixed hierarchy disabled - no children + if not self._mixed: + # print(" index: mixed hierarchy disabled") + return QModelIndex() + + # Mixed hierarchy: children of flattened rows + if parent.internalPointer() is None: + if parent.row() >= len(self._rows): + # print(" index: child-of-flat, parent.row out of bounds") + return QModelIndex() + rowinfo = self._rows[parent.row()] + + # Get the underlying QModelIndex from QPersistentModelIndex + if not rowinfo.leaf.isValid(): + # print(" index: child-of-flat, rowinfo.leaf invalid") + return QModelIndex() + + # Create a new QModelIndex from the persistent index data + src_parent = rowinfo.leaf.model().index( + rowinfo.leaf.row(), + rowinfo.leaf.column(), + QModelIndex() # parent - we're dealing with flattened items + ) + + # Only allow column 0 for children to avoid crashes + if col != 0: + # print(" index: child-of-flat, col != 0") + return QModelIndex() + + if row >= self.sourceModel().rowCount(src_parent): + # print(" index: child-of-flat, row out of src_parent bounds") + return QModelIndex() + + src_child = self.sourceModel().index(row, 0, src_parent) + if not src_child.isValid(): + # print(" index: child-of-flat, src_child invalid") + return QModelIndex() + + # Use the persistent source index directly as internal pointer + persistent = QPersistentModelIndex(src_child) + # Use hash of persistent index as internal pointer ID to avoid corruption + child_id = hash(persistent) % 2147483647 # Keep within reasonable range + if child_id < 0: + child_id = -child_id + if child_id == 0: + child_id = 1 # Avoid 0 which Qt might interpret as None + self._child_cache[child_id] = persistent + # print(f" index: child-of-flat, createIndex(row={row}, col={col}, ...") + return self.createIndex(row, col, child_id) + + # Grandchildren or deeper + elif parent.internalPointer() is not None: + # Get the cached QPersistentModelIndex + parent_id = parent.internalPointer() + if not isinstance(parent_id, int) or parent_id not in self._child_cache: + # print(f" index: grandchild, bad parent_id: {parent_id}") + return QModelIndex() + + try: + parent_persistent = self._child_cache[parent_id] + if not parent_persistent.isValid(): + # print(" index: grandchild, parent_persistent invalid") + return QModelIndex() + + # Create a new QModelIndex from the QPersistentModelIndex + src_parent = parent_persistent.model().index( + parent_persistent.row(), + parent_persistent.column(), + QModelIndex() # parent - simplified for now + ) + if not src_parent.isValid(): + # print(" index: grandchild, src_parent invalid") + return QModelIndex() + + # Only allow column 0 for children to avoid crashes + if col != 0: + # print(" index: grandchild, col != 0") + return QModelIndex() + + if row >= self.sourceModel().rowCount(src_parent): + # print(" index: grandchild, row out of src_parent bounds") + return QModelIndex() + + src_child = self.sourceModel().index(row, 0, src_parent) + if not src_child.isValid(): + # print(" index: grandchild, src_child invalid") + return QModelIndex() + + # Use ID-based system for grandchildren too + child_id = hash(src_child) % 2147483647 # Keep within reasonable range + if child_id < 0: + child_id = -child_id + if child_id == 0: + child_id = 1 # Avoid 0 which Qt might interpret as None + persistent = QPersistentModelIndex(src_child) + self._child_cache[child_id] = persistent + # print(f" index: grandchild, createIndex(row={row}, col={col}, ...") + return self.createIndex(row, col, child_id) + except Exception: + # print(f" index: grandchild exception: {e}") + return QModelIndex() + + # print(" index: fallback return invalid QModelIndex") + return QModelIndex() + + def parent(self, child: QModelIndex) -> QModelIndex: + # print(f"parent(child={child}, child.internalPointer={child.internalPointer() if child.isValid() else None})") + if not child.isValid() or self._level < 0: + # print(" parent: invalid child or passthrough") + return QModelIndex() + + # If this is a top-level flattened row, it has no parent + if child.internalPointer() is None: + # print(" parent: top-level row, no parent") + return QModelIndex() + + # If this is a mixed hierarchy child + if self._mixed and child.internalPointer() is not None: + child_id = child.internalPointer() + if not isinstance(child_id, int) or child_id not in self._child_cache: + # print(f" parent: bad child_id: {child_id}") + return QModelIndex() + try: + child_persistent = self._child_cache[child_id] + if not child_persistent.isValid(): + # print(" parent: child_persistent invalid") + return QModelIndex() + + # Create QModelIndex from QPersistentModelIndex properly + src_child = child_persistent.model().index( + child_persistent.row(), + child_persistent.column(), + QModelIndex() # simplified for now + ) + src_parent = src_child.parent() + + # Check if the source parent is one of our flattened rows + psrc_parent = QPersistentModelIndex(src_parent) + if psrc_parent in self._src2row: + parent_row = self._src2row[psrc_parent] + # print(f" parent: parent is top-level row {parent_row}") + return self.createIndex(parent_row, 0, None) + + # If not, this is a deeper level child + if src_parent.isValid(): + # Find the ID for the parent + parent_id = None + for cached_id, cached_persistent in self._child_cache.items(): + if cached_persistent.isValid() and QModelIndex(cached_persistent) == src_parent: + parent_id = cached_id + break + + if parent_id is None: + # Create new hash-based ID for parent + parent_id = hash(src_parent) % 2147483647 # Keep within reasonable range + if parent_id < 0: + parent_id = -parent_id + if parent_id == 0: + parent_id = 1 # Avoid 0 which Qt might interpret as None + self._child_cache[parent_id] = QPersistentModelIndex(src_parent) + + print(f" parent: deeper, createIndex({src_parent.row()}, 0, {parent_id})") + return self.createIndex(src_parent.row(), 0, parent_id) + + except Exception as e: + print(f" parent: exception: {e}") + + print(" parent: fallback return invalid QModelIndex") + return QModelIndex() + + def mapToSource(self, proxy: QModelIndex) -> QModelIndex: + # print(f"mapToSource(proxy={proxy}, proxy.internalPointer={proxy.internalPointer() if proxy.isValid() else None})") + if not proxy.isValid() or not self.sourceModel(): + print(" mapToSource: invalid proxy or no sourceModel") + return QModelIndex() + + if self._level < 0: + # Pass-through mode + try: + print(" mapToSource: passthrough") + return self.sourceModel().index(proxy.row(), proxy.column(), QModelIndex()) + except Exception as e: + print(f" mapToSource: passthrough exception: {e}") + return QModelIndex() + + # Top-level flattened row showing hierarchical path + if proxy.internalPointer() is None: + if proxy.row() >= len(self._rows): + print(" mapToSource: top-level, row out of bounds") + return QModelIndex() + row_info = self._rows[proxy.row()] + # Return the ancestor at the requested column level + if proxy.column() < len(row_info.ancestors): + ancestor = row_info.ancestors[proxy.column()] + # print(f" mapToSource: top-level, ancestor={ancestor}") + if isinstance(ancestor, QPersistentModelIndex) and ancestor.isValid(): + return QModelIndex(ancestor) + print(" mapToSource: top-level, fallback invalid") + return QModelIndex() + + # Mixed hierarchy child + if self._mixed and proxy.internalPointer() is not None: + ip = proxy.internalPointer() + if not isinstance(ip, int) or ip not in self._child_cache: + print(f" mapToSource: mixed, bad internalPointer: {ip}") + return QModelIndex() + try: + cached_persistent = self._child_cache[ip] + if not cached_persistent.isValid(): + print(" mapToSource: mixed, cached_persistent invalid") + return QModelIndex() + print(f" mapToSource: mixed, returning QModelIndex({cached_persistent})") + return QModelIndex(cached_persistent) + except Exception as e: + print(f" mapToSource: mixed, exception: {e}") + return QModelIndex() + + print(" mapToSource: fallback return invalid QModelIndex") + return QModelIndex() + + def mapFromSource(self, src: QModelIndex) -> QModelIndex: + if not src.isValid() or not self.sourceModel(): + return QModelIndex() + + if self._level < 0: + # Pass-through mode + try: + return self.createIndex(src.row(), src.column(), None) + except: + return QModelIndex() + + psrc = QPersistentModelIndex(src) + + # Check if this is a flattened row + if (row := self._src2row.get(psrc)) is not None: + return self.index(row, 0) + + # Check if this is in the ancestors of any flattened row + for r, info in enumerate(self._rows): + if psrc in info.ancestors: + col = info.ancestors.index(psrc) + return self.index(r, col) + + if self._mixed: + # For mixed hierarchy, create index with source as internal pointer + try: + src_parent = src.parent() + if not src_parent.isValid(): + # src is a top-level item, check if it's in our flattened rows + if psrc in self._src2row: + row = self._src2row[psrc] + return self.index(row, 0) + return QModelIndex() + else: + # src is a child - use hash-based system for internal pointer + child_id = hash(src) % 2147483647 # Keep within reasonable range + if child_id < 0: + child_id = -child_id + if child_id == 0: + child_id = 1 # Avoid 0 which Qt might interpret as None + self._child_cache[child_id] = QPersistentModelIndex(src) + return self.createIndex(src.row(), 0, child_id) + except: + return QModelIndex() + + return QModelIndex() + + def data(self, idx: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): + if not idx.isValid() or not self.sourceModel(): + return None try: - row = self._rows.index(source_index) - return self.createIndex(row, 0, source_index) - except ValueError: - return QtCore.QModelIndex() - - # ---- data - def data( - self, index: QtCore.QModelIndex, role: int = QtCore.Qt.ItemDataRole.DisplayRole - ): - if not index.isValid() or role != QtCore.Qt.ItemDataRole.DisplayRole: + return self.sourceModel().data(self.mapToSource(idx), role) + except: return None - src = self.mapToSource(index) - # walk up the tree: column 0 -> node, column 1 -> parent, etc. - node = src - for _ in range(index.column()): - node = node.parent() - return self.sourceModel().data(node, role) # type: ignore[arg-type] - def headerData( self, section: int, - orientation: QtCore.Qt.Orientation, - role: int = QtCore.Qt.ItemDataRole.DisplayRole, + orient: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, ): + if not self.sourceModel(): + return None + if ( - orientation == QtCore.Qt.Orientation.Horizontal - and role == QtCore.Qt.ItemDataRole.DisplayRole + orient == Qt.Orientation.Horizontal + and role == Qt.ItemDataRole.DisplayRole + and self._level >= 0 ): - return f"Level {self._row_depth - section}" - return self.sourceModel().headerData(section, orientation, role) # type: ignore[arg-type] - - # ------------- internal helpers ------------------------------------------ - def _rebuild_row_map(self) -> None: - self.beginResetModel() - self._rows.clear() - if src := self.sourceModel(): - self._collect_rows(QtCore.QModelIndex(), 0, src) - self.endResetModel() - - def _collect_rows( - self, - parent: QtCore.QModelIndex, - depth: int, - model: QtCore.QAbstractItemModel, - ) -> None: - if depth == self._row_depth: - for r in range(model.rowCount(parent)): - self._rows.append(model.index(r, 0, parent)) - return - for r in range(model.rowCount(parent)): - child = model.index(r, 0, parent) - self._collect_rows(child, depth + 1, model) - - -# ----------------------------------------------------------------------------- -# demo helpers -# ----------------------------------------------------------------------------- -def build_tree_model() -> QtGui.QStandardItemModel: - """Create a simple 5-level tree: A-i / B-j / C-k / D-l / E-m.""" - model = QtGui.QStandardItemModel() - model.setHorizontalHeaderLabels(["Name"]) - for ai in range(2): # A0, A1 - item_a = QtGui.QStandardItem(f"A{ai}") - for bj in range(2): - item_b = QtGui.QStandardItem(f"B{ai}{bj}") - for ck in range(2): - item_c = QtGui.QStandardItem(f"C{ai}{bj}{ck}") - for dl in range(2): - item_d = QtGui.QStandardItem(f"D{ai}{bj}{ck}{dl}") - for em in range(2): - item_e = QtGui.QStandardItem(f"E{ai}{bj}{ck}{dl}{em}") - item_d.appendRow(item_e) - item_c.appendRow(item_d) - item_b.appendRow(item_c) - item_a.appendRow(item_b) - model.appendRow(item_a) - return model - - -class MainWindow(QtWidgets.QWidget): - def __init__(self) -> None: + if section == 0: + return "A" + elif section == 1: + return "B" + elif section == 2: + return "C" + elif section == 3: + return "D" + elif section == 4: + return "E" + else: + return f"Level {section}" + + return self.sourceModel().headerData(section, orient, role) + + def setSourceModel(self, model): + if self.sourceModel(): + try: + self.sourceModel().modelReset.disconnect(self._rebuild) + except: + pass + super().setSourceModel(model) + if model: + model.modelReset.connect(self._rebuild) + self._rebuild() + + +# Demo window +class DemoWindow(QWidget): + def __init__(self): super().__init__() - self.setWindowTitle("Flatten proxy demo") - - # source tree - src_model = build_tree_model() - tree = QtWidgets.QTreeView() - tree.setModel(src_model) - tree.setHeaderHidden(False) - tree.expandAll() - - # proxy + table view - self.proxy = TreeFlatteningProxyModel(row_depth=4) - self.proxy.setSourceModel(src_model) - - sort_filter = QtCore.QSortFilterProxyModel() - sort_filter.setSourceModel(self.proxy) - sort_filter.setDynamicSortFilter(True) - - table = QtWidgets.QTableView() - table.setModel(sort_filter) - table.setSortingEnabled(True) - table.horizontalHeader().setStretchLastSection(True) - - # depth selector - depth_selector = QtWidgets.QComboBox() - depth_selector.addItems( - [ - "Rows = level E (depth 4)", - "Rows = level D (depth 3)", - "Rows = level C (depth 2)", - "Rows = level B (depth 1)", - ] - ) - depth_selector.setCurrentIndex(0) - - depth_selector.currentIndexChanged.connect( - lambda i: self.proxy.set_row_depth(4 - i) - ) - - # layout - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(QtWidgets.QLabel("Source tree")) - layout.addWidget(tree, 2) - layout.addWidget(QtWidgets.QLabel("Flattened table (sortable)")) - layout.addWidget(table, 3) - layout.addWidget(QtWidgets.QLabel("Choose row depth")) - layout.addWidget(depth_selector) - - -def main() -> None: - import sys - - app = QtWidgets.QApplication(sys.argv) - win = MainWindow() - win.resize(800, 600) - win.show() - sys.exit(app.exec()) + self.setWindowTitle("Tree Flatten Demo") + self.resize(1200, 600) + + # Create source model + self.src_model = QStandardItemModel() + self._populate_model() + + # Create proxy model + self.flatten = FlattenProxyModel(level=3) + self.flatten.setSourceModel(self.src_model) + + # Create layout + layout = QVBoxLayout(self) + + # Controls + controls = QHBoxLayout() + + # Level selector + controls.addWidget(QLabel("Flatten Level:")) + self.level_combo = QComboBox() + self.level_combo.addItems([ + "Pass-through", "Level 0 - A", "Level 1 - B", "Level 2 - C", "Level 3 - D" + ]) + self.level_combo.setCurrentIndex(4) # Level 3 - D + self.level_combo.currentIndexChanged.connect(self._on_level_changed) + controls.addWidget(self.level_combo) + + # Mixed hierarchy checkbox + self.mixed_check = QtWidgets.QCheckBox("Mixed Hierarchy") + self.mixed_check.setChecked(True) # Try with True to test + self.mixed_check.toggled.connect(self._on_mixed_changed) + controls.addWidget(self.mixed_check) + + controls.addStretch() + layout.addLayout(controls) + + # Views + views_layout = QHBoxLayout() + + # Original view + orig_view = QTreeView() + orig_view.setModel(self.src_model) + orig_view.expandAll() + orig_view.header().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + views_layout.addWidget(orig_view) + + # Flattened view + flat_view = QTreeView() + flat_view.setModel(self.flatten) + flat_view.expandAll() + flat_view.header().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + flat_view.header().setStretchLastSection(True) + views_layout.addWidget(flat_view) + + layout.addLayout(views_layout) + + # Set initial state + self._on_level_changed(4) # Level 3 - D + self._on_mixed_changed(True) # Try with mixed hierarchy + + def _populate_model(self): + root = self.src_model.invisibleRootItem() + for i in range(3): + item_a = QStandardItem(f"A{i}") + root.appendRow(item_a) + for j in range(3): + item_b = QStandardItem(f"B{j}") + item_a.appendRow(item_b) + for k in range(3): + item_c = QStandardItem(f"C{k}") + item_b.appendRow(item_c) + for l in range(3): + item_d = QStandardItem(f"D{l}") + item_c.appendRow(item_d) + for m in range(3): + item_e = QStandardItem(f"E{m}") + item_d.appendRow(item_e) + + def _parse_level_from_combo(self, index: int) -> int: + if index == 0: + return -1 # Pass-through + return index - 1 + + def _on_level_changed(self, index: int): + level = self._parse_level_from_combo(index) + self.flatten.setLevel(level) + + def _on_mixed_changed(self, checked: bool): + self.flatten.setMixed(checked) if __name__ == "__main__": - main() + app = QApplication(sys.argv) + window = DemoWindow() + window.show() + sys.exit(app.exec()) From d76841aa65693554525b5d5d70b841950a8e697c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 3 Jul 2025 11:00:26 -0400 Subject: [PATCH 40/70] add more example --- .../_models/_config_group_pivot_model.py | 2 ++ .../_views/_config_groups_editor.py | 26 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/pymmcore_widgets/_models/_config_group_pivot_model.py b/src/pymmcore_widgets/_models/_config_group_pivot_model.py index 68c55cb9b..319ab8b82 100644 --- a/src/pymmcore_widgets/_models/_config_group_pivot_model.py +++ b/src/pymmcore_widgets/_models/_config_group_pivot_model.py @@ -103,6 +103,8 @@ def _rebuild(self) -> None: # slot signature is flexible self.beginResetModel() 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 diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index 706cdc28f..7989c0d31 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -23,6 +23,9 @@ get_config_groups, get_loaded_devices, ) +from pymmcore_widgets.config_presets._views._config_presets_table import ( + ConfigPresetsTable, +) if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -77,6 +80,9 @@ def update_from_core( if update_configs: self.setData(get_config_groups(core)) self._prop_selector.setAvailableDevices(get_loaded_devices(core)) + self._preset_table.setModel(self._model) + self._preset_table.setGroup("Channel") + # if update_available: # self._props._update_device_buttons(core) # self._prop_tables.update_options_from_core(core) @@ -87,6 +93,10 @@ def __init__(self, parent: QWidget | None = None) -> None: # widgets -------------------------------------------------------------- self._tb = QToolBar(self) + self._tb.addAction("Add Group") + self._tb.addAction("Add Preset") + self._tb.addAction("Remove") + self._tb.addAction("Duplicate") self.group_list = QListView(self) self.group_list.setModel(self._model) @@ -98,6 +108,9 @@ def __init__(self, parent: QWidget | None = None) -> None: self._prop_selector = DevicePropertySelector() + self._preset_table = ConfigPresetsTable(self) + self._preset_table.setModel(self._model) + self._preset_table.setGroup("Channel") # layout ------------------------------------------------------------ top = QSplitter(Qt.Orientation.Horizontal, self) @@ -105,11 +118,16 @@ def __init__(self, parent: QWidget | None = None) -> None: top.addWidget(self.preset_list) top.addWidget(self._prop_selector) - main_splitter = QSplitter(Qt.Orientation.Horizontal, self) + top_splitter = QSplitter(Qt.Orientation.Horizontal, self) + top_splitter.setHandleWidth(1) + top_splitter.setChildrenCollapsible(False) + top_splitter.addWidget(top) + # top_splitter.addWidget(preset_box) + + main_splitter = QSplitter(Qt.Orientation.Vertical, self) main_splitter.setHandleWidth(1) - main_splitter.setChildrenCollapsible(False) - main_splitter.addWidget(top) - # main_splitter.addWidget(preset_box) + main_splitter.addWidget(top_splitter) + main_splitter.addWidget(self._preset_table) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) From 8108c33a7bb0b664896d60da36da8441ff5e74f9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 3 Jul 2025 14:50:02 -0400 Subject: [PATCH 41/70] remove --- src/pymmcore_widgets/_models/_flat_chatgpt.py | 628 ------------------ src/pymmcore_widgets/_models/_flat_claude.py | 356 ---------- src/pymmcore_widgets/_models/_flat_gemini.py | 391 ----------- 3 files changed, 1375 deletions(-) delete mode 100644 src/pymmcore_widgets/_models/_flat_chatgpt.py delete mode 100644 src/pymmcore_widgets/_models/_flat_claude.py delete mode 100644 src/pymmcore_widgets/_models/_flat_gemini.py diff --git a/src/pymmcore_widgets/_models/_flat_chatgpt.py b/src/pymmcore_widgets/_models/_flat_chatgpt.py deleted file mode 100644 index 4a703b2f6..000000000 --- a/src/pymmcore_widgets/_models/_flat_chatgpt.py +++ /dev/null @@ -1,628 +0,0 @@ -from __future__ import annotations - -import sys -from typing import NamedTuple - -from qtpy import QtCore, QtWidgets -from qtpy.QtCore import QAbstractProxyModel, QModelIndex, QPersistentModelIndex, Qt -from qtpy.QtGui import QStandardItem, QStandardItemModel -from qtpy.QtWidgets import ( - QApplication, - QComboBox, - QHBoxLayout, - QHeaderView, - QLabel, - QTreeView, - QVBoxLayout, - QWidget, -) - - -class RowInfo(NamedTuple): - leaf: QPersistentModelIndex - ancestors: list[QPersistentModelIndex] - - -class FlattenProxyModel(QAbstractProxyModel): - def __init__(self, level: int = 0, parent: QtCore.QObject | None = None): - super().__init__(parent) - self._level = level - self._rows: list[RowInfo] = [] - self._src2row: dict[QPersistentModelIndex, int] = {} - self._mixed = False # Whether to show mixed hierarchy - self._child_cache: dict[int, QPersistentModelIndex] = {} # Cache for child indices - - def setLevel(self, level: int): - self._level = level - self._rebuild() - - def setMixed(self, mixed: bool): - self._mixed = mixed - self._rebuild() - - def _rebuild(self): - self.beginResetModel() - self._rows.clear() - self._src2row.clear() - self._child_cache.clear() - self._child_cache.clear() - - if not self.sourceModel(): - self.endResetModel() - return - - if self._level < 0: - # Pass-through mode - self.endResetModel() - return - - # Build flattened rows - self._traverse(QModelIndex(), []) - self.endResetModel() - - def _traverse(self, parent: QModelIndex, ancestors: list[QPersistentModelIndex]): - for r in range(self.sourceModel().rowCount(parent)): - child = self.sourceModel().index(r, 0, parent) - child_ancestors = [*ancestors, QPersistentModelIndex(child)] - - if len(child_ancestors) - 1 == self._level: - # This is a leaf at the desired level - row_info = RowInfo(QPersistentModelIndex(child), child_ancestors) - self._src2row[QPersistentModelIndex(child)] = len(self._rows) - self._rows.append(row_info) - elif len(child_ancestors) - 1 < self._level: - # Keep going deeper - self._traverse(child, child_ancestors) - - def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: - if not self.sourceModel(): - return 0 - - if self._level < 0: - # Pass-through mode - return self.sourceModel().rowCount(self.mapToSource(parent)) - - if not parent.isValid(): - # Top-level: return number of flattened rows - return len(self._rows) - - # Mixed hierarchy: children of flattened rows - if self._mixed and not parent.internalPointer(): - # Children of a flattened row - try: - rowinfo = self._rows[parent.row()] - src_parent = QModelIndex(rowinfo.leaf) - return self.sourceModel().rowCount(src_parent) - except: - return 0 - - # Grandchildren or deeper - if self._mixed and parent.internalPointer(): - try: - parent_id = parent.internalPointer() - if isinstance(parent_id, int) and parent_id in self._child_cache: - src_parent_persistent = self._child_cache[parent_id] - if src_parent_persistent.isValid(): - # Create new QModelIndex from QPersistentModelIndex - src_parent = self.sourceModel().index( - src_parent_persistent.row(), - src_parent_persistent.column(), - QModelIndex() # Simplified for now - ) - return self.sourceModel().rowCount(src_parent) - except: - pass - return 0 - - return 0 - - def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: - if not self.sourceModel(): - return 0 - if self._level < 0: - # Pass-through mode - return self.sourceModel().columnCount() - # In flattened mode, show columns for each level in the hierarchy - return self._level + 1 - - def hasChildren(self, parent: QModelIndex = QModelIndex()) -> bool: - if not self.sourceModel(): - return False - - if self._level < 0: - # Pass-through mode - src_index = self.mapToSource(parent) - return self.sourceModel().hasChildren(src_index) - - if not parent.isValid(): - # Top-level always has children (the flattened rows) - return len(self._rows) > 0 - - # Mixed hierarchy: check if flattened rows have children - if self._mixed and not parent.internalPointer(): - try: - if parent.row() < len(self._rows): - rowinfo = self._rows[parent.row()] - # Recreate QModelIndex from QPersistentModelIndex using its data - if rowinfo.leaf.isValid(): - # Get the parent of the leaf to recreate the hierarchy - persistent_parent = rowinfo.leaf.parent() if rowinfo.leaf.parent().isValid() else QModelIndex() - src_index = self.sourceModel().index( - rowinfo.leaf.row(), - rowinfo.leaf.column(), - persistent_parent - ) - return self.sourceModel().hasChildren(src_index) - except Exception: - pass - return False - - # Grandchildren or deeper - if self._mixed and parent.internalPointer(): - try: - parent_id = parent.internalPointer() - if isinstance(parent_id, int) and parent_id in self._child_cache: - src_parent_persistent = self._child_cache[parent_id] - if src_parent_persistent.isValid(): - # Create new QModelIndex from QPersistentModelIndex - src_parent = self.sourceModel().index( - src_parent_persistent.row(), - src_parent_persistent.column(), - QModelIndex() # Simplified for now - ) - return self.sourceModel().hasChildren(src_parent) - except: - pass - return False - - return False - - def index( - self, row: int, col: int, parent: QModelIndex = QModelIndex() - ) -> QModelIndex: - # print(f"index(row={row}, col={col}, parent={parent}, parent.internalPointer={parent.internalPointer() if parent.isValid() else None})") - if row < 0 or col < 0: - # print(" index: negative row/col") - return QModelIndex() - - if not self.sourceModel(): - # print(" index: no sourceModel") - return QModelIndex() - - # Pass-through mode - if self._level < 0: - try: - src_parent = self.mapToSource(parent) - if not src_parent.isValid() and parent.isValid(): - # print(" index: passthrough, invalid src_parent") - return QModelIndex() - # print(" index: passthrough, createIndex with None") - return self.createIndex(row, col, None) - except Exception: - # print(f" index: passthrough exception: {e}") - return QModelIndex() - - # Top-level flattened row - if not parent.isValid(): - if row >= len(self._rows) or col >= self.columnCount(): - # print(" index: top-level out of bounds") - return QModelIndex() - # print(" index: top-level createIndex with None") - return self.createIndex(row, col, None) - - # Mixed hierarchy disabled - no children - if not self._mixed: - # print(" index: mixed hierarchy disabled") - return QModelIndex() - - # Mixed hierarchy: children of flattened rows - if parent.internalPointer() is None: - if parent.row() >= len(self._rows): - # print(" index: child-of-flat, parent.row out of bounds") - return QModelIndex() - rowinfo = self._rows[parent.row()] - - # Get the underlying QModelIndex from QPersistentModelIndex - if not rowinfo.leaf.isValid(): - # print(" index: child-of-flat, rowinfo.leaf invalid") - return QModelIndex() - - # Create a new QModelIndex from the persistent index data - src_parent = rowinfo.leaf.model().index( - rowinfo.leaf.row(), - rowinfo.leaf.column(), - QModelIndex() # parent - we're dealing with flattened items - ) - - # Only allow column 0 for children to avoid crashes - if col != 0: - # print(" index: child-of-flat, col != 0") - return QModelIndex() - - if row >= self.sourceModel().rowCount(src_parent): - # print(" index: child-of-flat, row out of src_parent bounds") - return QModelIndex() - - src_child = self.sourceModel().index(row, 0, src_parent) - if not src_child.isValid(): - # print(" index: child-of-flat, src_child invalid") - return QModelIndex() - - # Use the persistent source index directly as internal pointer - persistent = QPersistentModelIndex(src_child) - # Use hash of persistent index as internal pointer ID to avoid corruption - child_id = hash(persistent) % 2147483647 # Keep within reasonable range - if child_id < 0: - child_id = -child_id - if child_id == 0: - child_id = 1 # Avoid 0 which Qt might interpret as None - self._child_cache[child_id] = persistent - # print(f" index: child-of-flat, createIndex(row={row}, col={col}, ...") - return self.createIndex(row, col, child_id) - - # Grandchildren or deeper - elif parent.internalPointer() is not None: - # Get the cached QPersistentModelIndex - parent_id = parent.internalPointer() - if not isinstance(parent_id, int) or parent_id not in self._child_cache: - # print(f" index: grandchild, bad parent_id: {parent_id}") - return QModelIndex() - - try: - parent_persistent = self._child_cache[parent_id] - if not parent_persistent.isValid(): - # print(" index: grandchild, parent_persistent invalid") - return QModelIndex() - - # Create a new QModelIndex from the QPersistentModelIndex - src_parent = parent_persistent.model().index( - parent_persistent.row(), - parent_persistent.column(), - QModelIndex() # parent - simplified for now - ) - if not src_parent.isValid(): - # print(" index: grandchild, src_parent invalid") - return QModelIndex() - - # Only allow column 0 for children to avoid crashes - if col != 0: - # print(" index: grandchild, col != 0") - return QModelIndex() - - if row >= self.sourceModel().rowCount(src_parent): - # print(" index: grandchild, row out of src_parent bounds") - return QModelIndex() - - src_child = self.sourceModel().index(row, 0, src_parent) - if not src_child.isValid(): - # print(" index: grandchild, src_child invalid") - return QModelIndex() - - # Use ID-based system for grandchildren too - child_id = hash(src_child) % 2147483647 # Keep within reasonable range - if child_id < 0: - child_id = -child_id - if child_id == 0: - child_id = 1 # Avoid 0 which Qt might interpret as None - persistent = QPersistentModelIndex(src_child) - self._child_cache[child_id] = persistent - # print(f" index: grandchild, createIndex(row={row}, col={col}, ...") - return self.createIndex(row, col, child_id) - except Exception: - # print(f" index: grandchild exception: {e}") - return QModelIndex() - - # print(" index: fallback return invalid QModelIndex") - return QModelIndex() - - def parent(self, child: QModelIndex) -> QModelIndex: - # print(f"parent(child={child}, child.internalPointer={child.internalPointer() if child.isValid() else None})") - if not child.isValid() or self._level < 0: - # print(" parent: invalid child or passthrough") - return QModelIndex() - - # If this is a top-level flattened row, it has no parent - if child.internalPointer() is None: - # print(" parent: top-level row, no parent") - return QModelIndex() - - # If this is a mixed hierarchy child - if self._mixed and child.internalPointer() is not None: - child_id = child.internalPointer() - if not isinstance(child_id, int) or child_id not in self._child_cache: - # print(f" parent: bad child_id: {child_id}") - return QModelIndex() - try: - child_persistent = self._child_cache[child_id] - if not child_persistent.isValid(): - # print(" parent: child_persistent invalid") - return QModelIndex() - - # Create QModelIndex from QPersistentModelIndex properly - src_child = child_persistent.model().index( - child_persistent.row(), - child_persistent.column(), - QModelIndex() # simplified for now - ) - src_parent = src_child.parent() - - # Check if the source parent is one of our flattened rows - psrc_parent = QPersistentModelIndex(src_parent) - if psrc_parent in self._src2row: - parent_row = self._src2row[psrc_parent] - # print(f" parent: parent is top-level row {parent_row}") - return self.createIndex(parent_row, 0, None) - - # If not, this is a deeper level child - if src_parent.isValid(): - # Find the ID for the parent - parent_id = None - for cached_id, cached_persistent in self._child_cache.items(): - if cached_persistent.isValid() and QModelIndex(cached_persistent) == src_parent: - parent_id = cached_id - break - - if parent_id is None: - # Create new hash-based ID for parent - parent_id = hash(src_parent) % 2147483647 # Keep within reasonable range - if parent_id < 0: - parent_id = -parent_id - if parent_id == 0: - parent_id = 1 # Avoid 0 which Qt might interpret as None - self._child_cache[parent_id] = QPersistentModelIndex(src_parent) - - print(f" parent: deeper, createIndex({src_parent.row()}, 0, {parent_id})") - return self.createIndex(src_parent.row(), 0, parent_id) - - except Exception as e: - print(f" parent: exception: {e}") - - print(" parent: fallback return invalid QModelIndex") - return QModelIndex() - - def mapToSource(self, proxy: QModelIndex) -> QModelIndex: - # print(f"mapToSource(proxy={proxy}, proxy.internalPointer={proxy.internalPointer() if proxy.isValid() else None})") - if not proxy.isValid() or not self.sourceModel(): - print(" mapToSource: invalid proxy or no sourceModel") - return QModelIndex() - - if self._level < 0: - # Pass-through mode - try: - print(" mapToSource: passthrough") - return self.sourceModel().index(proxy.row(), proxy.column(), QModelIndex()) - except Exception as e: - print(f" mapToSource: passthrough exception: {e}") - return QModelIndex() - - # Top-level flattened row showing hierarchical path - if proxy.internalPointer() is None: - if proxy.row() >= len(self._rows): - print(" mapToSource: top-level, row out of bounds") - return QModelIndex() - row_info = self._rows[proxy.row()] - # Return the ancestor at the requested column level - if proxy.column() < len(row_info.ancestors): - ancestor = row_info.ancestors[proxy.column()] - # print(f" mapToSource: top-level, ancestor={ancestor}") - if isinstance(ancestor, QPersistentModelIndex) and ancestor.isValid(): - return QModelIndex(ancestor) - print(" mapToSource: top-level, fallback invalid") - return QModelIndex() - - # Mixed hierarchy child - if self._mixed and proxy.internalPointer() is not None: - ip = proxy.internalPointer() - if not isinstance(ip, int) or ip not in self._child_cache: - print(f" mapToSource: mixed, bad internalPointer: {ip}") - return QModelIndex() - try: - cached_persistent = self._child_cache[ip] - if not cached_persistent.isValid(): - print(" mapToSource: mixed, cached_persistent invalid") - return QModelIndex() - print(f" mapToSource: mixed, returning QModelIndex({cached_persistent})") - return QModelIndex(cached_persistent) - except Exception as e: - print(f" mapToSource: mixed, exception: {e}") - return QModelIndex() - - print(" mapToSource: fallback return invalid QModelIndex") - return QModelIndex() - - def mapFromSource(self, src: QModelIndex) -> QModelIndex: - if not src.isValid() or not self.sourceModel(): - return QModelIndex() - - if self._level < 0: - # Pass-through mode - try: - return self.createIndex(src.row(), src.column(), None) - except: - return QModelIndex() - - psrc = QPersistentModelIndex(src) - - # Check if this is a flattened row - if (row := self._src2row.get(psrc)) is not None: - return self.index(row, 0) - - # Check if this is in the ancestors of any flattened row - for r, info in enumerate(self._rows): - if psrc in info.ancestors: - col = info.ancestors.index(psrc) - return self.index(r, col) - - if self._mixed: - # For mixed hierarchy, create index with source as internal pointer - try: - src_parent = src.parent() - if not src_parent.isValid(): - # src is a top-level item, check if it's in our flattened rows - if psrc in self._src2row: - row = self._src2row[psrc] - return self.index(row, 0) - return QModelIndex() - else: - # src is a child - use hash-based system for internal pointer - child_id = hash(src) % 2147483647 # Keep within reasonable range - if child_id < 0: - child_id = -child_id - if child_id == 0: - child_id = 1 # Avoid 0 which Qt might interpret as None - self._child_cache[child_id] = QPersistentModelIndex(src) - return self.createIndex(src.row(), 0, child_id) - except: - return QModelIndex() - - return QModelIndex() - - def data(self, idx: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): - if not idx.isValid() or not self.sourceModel(): - return None - try: - return self.sourceModel().data(self.mapToSource(idx), role) - except: - return None - - def headerData( - self, - section: int, - orient: Qt.Orientation, - role: int = Qt.ItemDataRole.DisplayRole, - ): - if not self.sourceModel(): - return None - - if ( - orient == Qt.Orientation.Horizontal - and role == Qt.ItemDataRole.DisplayRole - and self._level >= 0 - ): - if section == 0: - return "A" - elif section == 1: - return "B" - elif section == 2: - return "C" - elif section == 3: - return "D" - elif section == 4: - return "E" - else: - return f"Level {section}" - - return self.sourceModel().headerData(section, orient, role) - - def setSourceModel(self, model): - if self.sourceModel(): - try: - self.sourceModel().modelReset.disconnect(self._rebuild) - except: - pass - super().setSourceModel(model) - if model: - model.modelReset.connect(self._rebuild) - self._rebuild() - - -# Demo window -class DemoWindow(QWidget): - def __init__(self): - super().__init__() - self.setWindowTitle("Tree Flatten Demo") - self.resize(1200, 600) - - # Create source model - self.src_model = QStandardItemModel() - self._populate_model() - - # Create proxy model - self.flatten = FlattenProxyModel(level=3) - self.flatten.setSourceModel(self.src_model) - - # Create layout - layout = QVBoxLayout(self) - - # Controls - controls = QHBoxLayout() - - # Level selector - controls.addWidget(QLabel("Flatten Level:")) - self.level_combo = QComboBox() - self.level_combo.addItems([ - "Pass-through", "Level 0 - A", "Level 1 - B", "Level 2 - C", "Level 3 - D" - ]) - self.level_combo.setCurrentIndex(4) # Level 3 - D - self.level_combo.currentIndexChanged.connect(self._on_level_changed) - controls.addWidget(self.level_combo) - - # Mixed hierarchy checkbox - self.mixed_check = QtWidgets.QCheckBox("Mixed Hierarchy") - self.mixed_check.setChecked(True) # Try with True to test - self.mixed_check.toggled.connect(self._on_mixed_changed) - controls.addWidget(self.mixed_check) - - controls.addStretch() - layout.addLayout(controls) - - # Views - views_layout = QHBoxLayout() - - # Original view - orig_view = QTreeView() - orig_view.setModel(self.src_model) - orig_view.expandAll() - orig_view.header().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) - views_layout.addWidget(orig_view) - - # Flattened view - flat_view = QTreeView() - flat_view.setModel(self.flatten) - flat_view.expandAll() - flat_view.header().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) - flat_view.header().setStretchLastSection(True) - views_layout.addWidget(flat_view) - - layout.addLayout(views_layout) - - # Set initial state - self._on_level_changed(4) # Level 3 - D - self._on_mixed_changed(True) # Try with mixed hierarchy - - def _populate_model(self): - root = self.src_model.invisibleRootItem() - for i in range(3): - item_a = QStandardItem(f"A{i}") - root.appendRow(item_a) - for j in range(3): - item_b = QStandardItem(f"B{j}") - item_a.appendRow(item_b) - for k in range(3): - item_c = QStandardItem(f"C{k}") - item_b.appendRow(item_c) - for l in range(3): - item_d = QStandardItem(f"D{l}") - item_c.appendRow(item_d) - for m in range(3): - item_e = QStandardItem(f"E{m}") - item_d.appendRow(item_e) - - def _parse_level_from_combo(self, index: int) -> int: - if index == 0: - return -1 # Pass-through - return index - 1 - - def _on_level_changed(self, index: int): - level = self._parse_level_from_combo(index) - self.flatten.setLevel(level) - - def _on_mixed_changed(self, checked: bool): - self.flatten.setMixed(checked) - - -if __name__ == "__main__": - app = QApplication(sys.argv) - window = DemoWindow() - window.show() - sys.exit(app.exec()) diff --git a/src/pymmcore_widgets/_models/_flat_claude.py b/src/pymmcore_widgets/_models/_flat_claude.py deleted file mode 100644 index 1406b0385..000000000 --- a/src/pymmcore_widgets/_models/_flat_claude.py +++ /dev/null @@ -1,356 +0,0 @@ -import sys -from enum import Enum -from typing import NamedTuple - -from PyQt6.QtCore import QAbstractProxyModel, QModelIndex, Qt -from PyQt6.QtGui import QStandardItem, QStandardItemModel -from PyQt6.QtWidgets import ( - QApplication, - QHBoxLayout, - QMainWindow, - QPushButton, - QTableView, - QTreeView, - QVBoxLayout, - QWidget, -) - - -class FlattenMode(Enum): - FLATTEN_TO_LEVEL = "flatten_to_level" - FLATTEN_WITH_EXPANSION = "flatten_with_expansion" - - -class FlattenedItem(NamedTuple): - source_index: QModelIndex - path: list[QModelIndex] - is_expanded: bool - display_level: int - - -class TreeFlatteningProxyModel(QAbstractProxyModel): - """ - A proxy model that flattens a tree structure to show specified levels - as rows in a table format. - """ - - def __init__(self, parent=None): - super().__init__(parent) - self._flattened_items: list[FlattenedItem] = [] - self._flatten_depth = 0 - self._mode = FlattenMode.FLATTEN_TO_LEVEL - self._secondary_depth = -1 # For expandable mode - - def setSourceModel(self, model): - """Set the source model and rebuild the flattened structure.""" - if self.sourceModel(): - self.sourceModel().dataChanged.disconnect(self._on_source_data_changed) - self.sourceModel().rowsInserted.disconnect(self._on_source_rows_inserted) - self.sourceModel().rowsRemoved.disconnect(self._on_source_rows_removed) - self.sourceModel().modelReset.disconnect(self._on_source_model_reset) - - super().setSourceModel(model) - - if model: - model.dataChanged.connect(self._on_source_data_changed) - model.rowsInserted.connect(self._on_source_rows_inserted) - model.rowsRemoved.connect(self._on_source_rows_removed) - model.modelReset.connect(self._on_source_model_reset) - - self._rebuild_flattened_structure() - - def set_flatten_depth(self, depth: int): - """Set the depth level to flatten to.""" - self._flatten_depth = depth - self._rebuild_flattened_structure() - - def set_flatten_mode(self, mode: FlattenMode): - """Set the flattening mode.""" - self._mode = mode - self._rebuild_flattened_structure() - - def set_flatten_configuration(self, primary_level: int, secondary_level: int = -1): - """Set both primary and secondary levels for complex flattening.""" - self._flatten_depth = primary_level - self._secondary_depth = secondary_level - self._rebuild_flattened_structure() - - def _rebuild_flattened_structure(self): - """Rebuild the entire flattened structure.""" - self.beginResetModel() - self._flattened_items.clear() - - if not self.sourceModel(): - self.endResetModel() - return - - self._build_flattened_items(QModelIndex(), [], 0) - self.endResetModel() - - def _build_flattened_items( - self, parent: QModelIndex, current_path: list[QModelIndex], current_depth: int - ): - """Recursively build the flattened items structure.""" - if not self.sourceModel(): - return - - row_count = self.sourceModel().rowCount(parent) - - for i in range(row_count): - child = self.sourceModel().index(i, 0, parent) - if not child.isValid(): - continue - - child_path = [*current_path, child] - - # Check if this is our target level - if current_depth == self._flatten_depth: - item = FlattenedItem( - source_index=child, - path=child_path, - is_expanded=False, - display_level=current_depth, - ) - self._flattened_items.append(item) - - # For expandable mode, also add intermediate levels - elif ( - self._mode == FlattenMode.FLATTEN_WITH_EXPANSION - and self._secondary_depth != -1 - and current_depth == self._secondary_depth - ): - item = FlattenedItem( - source_index=child, - path=child_path, - is_expanded=False, - display_level=current_depth, - ) - self._flattened_items.append(item) - - # Continue recursion if there are children - if self.sourceModel().hasChildren(child): - self._build_flattened_items(child, child_path, current_depth + 1) - - def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: - """Return the number of flattened items.""" - if parent.isValid(): - return 0 # Flat structure, no children - return len(self._flattened_items) - - def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: - """Return the number of columns (levels in the path).""" - if not self._flattened_items: - return 0 - return self._flatten_depth + 1 - - def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): - """Return data for the given index.""" - if not index.isValid() or index.row() >= len(self._flattened_items): - return None - - item = self._flattened_items[index.row()] - - # Map columns to different levels of the path - if index.column() < len(item.path): - source_idx = item.path[index.column()] - return self.sourceModel().data(source_idx, role) - - return None - - def headerData( - self, - section: int, - orientation: Qt.Orientation, - role: int = Qt.ItemDataRole.DisplayRole, - ): - """Return header data.""" - if ( - role == Qt.ItemDataRole.DisplayRole - and orientation == Qt.Orientation.Horizontal - ): - return f"Level {section}" - return None - - def index( - self, row: int, column: int, parent: QModelIndex = QModelIndex() - ) -> QModelIndex: - """Create an index for the given row and column.""" - if ( - parent.isValid() - or row < 0 - or row >= len(self._flattened_items) - or column < 0 - or column >= self.columnCount() - ): - return QModelIndex() - - return self.createIndex(row, column, row) # Use row as internal pointer - - def parent(self, index: QModelIndex) -> QModelIndex: - """Return parent index (always invalid for flat structure).""" - return QModelIndex() - - def mapToSource(self, proxy_index: QModelIndex) -> QModelIndex: - """Map proxy index to source index.""" - if not proxy_index.isValid() or proxy_index.row() >= len(self._flattened_items): - return QModelIndex() - - item = self._flattened_items[proxy_index.row()] - if proxy_index.column() < len(item.path): - return item.path[proxy_index.column()] - - return QModelIndex() - - def mapFromSource(self, source_index: QModelIndex) -> QModelIndex: - """Map source index to proxy index.""" - if not source_index.isValid(): - return QModelIndex() - - # Find the flattened item that contains this source index - for row, item in enumerate(self._flattened_items): - if source_index in item.path: - col = item.path.index(source_index) - return self.index(row, col) - - return QModelIndex() - - def toggle_expansion(self, proxy_index: QModelIndex): - """Toggle expansion state for expandable items.""" - if not proxy_index.isValid() or proxy_index.row() >= len(self._flattened_items): - return - - # This is where you'd implement expansion logic - # For now, just rebuild - in a real implementation you'd be more selective - self._rebuild_flattened_structure() - - # Source model change handlers - def _on_source_data_changed( - self, top_left: QModelIndex, bottom_right: QModelIndex, roles: list[int] - ): - """Handle source model data changes.""" - # Simple approach: rebuild everything - # In a real implementation, you'd be more selective - self._rebuild_flattened_structure() - - def _on_source_rows_inserted(self, parent: QModelIndex, first: int, last: int): - """Handle source model row insertion.""" - self._rebuild_flattened_structure() - - def _on_source_rows_removed(self, parent: QModelIndex, first: int, last: int): - """Handle source model row removal.""" - self._rebuild_flattened_structure() - - def _on_source_model_reset(self): - """Handle source model reset.""" - self._rebuild_flattened_structure() - - -if __name__ == "__main__": - app = QApplication(sys.argv) - - class TreeFlatteningDemo(QMainWindow): - """Demo application showing the tree flattening proxy model in action.""" - - def __init__(self): - super().__init__() - self.setWindowTitle("Tree Flattening Proxy Model Demo") - self.setGeometry(100, 100, 800, 600) - - # Create the source model with sample data - self.source_model = self._create_sample_model() - - # Create the proxy model - self.proxy_model = TreeFlatteningProxyModel() - self.proxy_model.setSourceModel(self.source_model) - - self._setup_ui() - - # Set initial configuration - self.proxy_model.set_flatten_depth(4) # Show all E's - - def _create_sample_model(self) -> QStandardItemModel: - """Create a sample tree model with A>B>C>D>E structure.""" - model = QStandardItemModel() - model.setHorizontalHeaderLabels(["Name"]) - - # Create sample data: A > B > C > D > E - for a_idx in range(2): - item_a = QStandardItem(f"A{a_idx + 1}") - model.appendRow(item_a) - - for b_idx in range(3): - item_b = QStandardItem(f"B{b_idx + 1}") - item_a.appendRow(item_b) - - for c_idx in range(2): - item_c = QStandardItem(f"C{c_idx + 1}") - item_b.appendRow(item_c) - - for d_idx in range(2): - item_d = QStandardItem(f"D{d_idx + 1}") - item_c.appendRow(item_d) - - for e_idx in range(3): - item_e = QStandardItem(f"E{e_idx + 1}") - item_d.appendRow(item_e) - - return model - - def _setup_ui(self): - """Setup the user interface.""" - central_widget = QWidget() - self.setCentralWidget(central_widget) - layout = QVBoxLayout(central_widget) - - # Control buttons - button_layout = QHBoxLayout() - - btn_show_all_e = QPushButton("Show All E's") - btn_show_all_e.clicked.connect( - lambda: self.proxy_model.set_flatten_depth(4) - ) - - btn_show_all_d = QPushButton("Show All D's") - btn_show_all_d.clicked.connect( - lambda: self.proxy_model.set_flatten_depth(3) - ) - - btn_show_all_c = QPushButton("Show All C's") - btn_show_all_c.clicked.connect( - lambda: self.proxy_model.set_flatten_depth(2) - ) - - btn_show_all_b = QPushButton("Show All B's") - btn_show_all_b.clicked.connect( - lambda: self.proxy_model.set_flatten_depth(1) - ) - - button_layout.addWidget(btn_show_all_e) - button_layout.addWidget(btn_show_all_d) - button_layout.addWidget(btn_show_all_c) - button_layout.addWidget(btn_show_all_b) - - layout.addLayout(button_layout) - - # Views layout - views_layout = QHBoxLayout() - - # Original tree view - self.tree_view = QTreeView() - self.tree_view.setModel(self.source_model) - self.tree_view.expandAll() - - # Flattened table view - self.table_view = QTableView() - self.table_view.setModel(self.proxy_model) - self.table_view.setSortingEnabled(True) - - views_layout.addWidget(self.tree_view) - views_layout.addWidget(self.table_view) - - layout.addLayout(views_layout) - - demo = TreeFlatteningDemo() - demo.show() - - sys.exit(app.exec()) diff --git a/src/pymmcore_widgets/_models/_flat_gemini.py b/src/pymmcore_widgets/_models/_flat_gemini.py deleted file mode 100644 index d64336f58..000000000 --- a/src/pymmcore_widgets/_models/_flat_gemini.py +++ /dev/null @@ -1,391 +0,0 @@ -import sys - -from PyQt6.QtCore import ( - QAbstractProxyModel, - QModelIndex, - QPersistentModelIndex, - QSortFilterProxyModel, - Qt, -) -from PyQt6.QtGui import QStandardItem, QStandardItemModel -from PyQt6.QtWidgets import ( - QApplication, - QComboBox, - QHeaderView, - QLabel, - QLineEdit, - QMainWindow, - QTreeView, - QVBoxLayout, - QWidget, -) - - -class TreeFlatteningProxyModel(QAbstractProxyModel): - """ - A proxy model that can flatten a source tree model to an arbitrary depth - or present a mixed hierarchy. - - - Level -1 (Default): Pass-through, acts like a normal tree. - - Level 0: Flatten at level 'A'. Each row is an 'A' item. - - Level 1: Flatten at level 'B'. Each row is a 'B' item. - ...and so on. - - When a level is chosen for flattening, all ancestor data is presented - in columns. For levels below the flattening level, the model can - expose the hierarchy, allowing a QTreeView to expand/collapse children. - """ - - def __init__(self, parent=None): - super().__init__(parent) - self._flattening_level = -1 - # _source_map will store QPersistentModelIndex objects of the source items - # that should be treated as top-level rows in the proxy. - self._source_map = [] - - def setFlatteningLevel(self, level): - """ - Sets the depth to which the source model is flattened. - -1 means no flattening (pass-through). - 0 means flatten at the root level, 1 at the next, etc. - """ - if self._flattening_level == level: - return - - self.beginResetModel() - self._flattening_level = level - self._source_map.clear() - - if self._flattening_level > -1: - # Build the map of source indexes that will become our top-level rows. - self._build_map_recursive(self.sourceModel().invisibleRootItem().index(), 0) - - self.endResetModel() - - def _build_map_recursive(self, parent_source_index, depth): - """ - Recursively traverses the source model to find all items - at the target flattening level. - """ - source_model = self.sourceModel() - for row in range(source_model.rowCount(parent_source_index)): - child_source_index = source_model.index(row, 0, parent_source_index) - if not child_source_index.isValid(): - continue - - if depth == self._flattening_level: - # We found an item at the target level. Add it to our map. - # Use QPersistentModelIndex because the source model could change. - self._source_map.append(QPersistentModelIndex(child_source_index)) - elif depth < self._flattening_level: - # We haven't reached the target depth yet, so go deeper. - self._build_map_recursive(child_source_index, depth + 1) - - def mapToSource(self, proxy_index): - """Maps a proxy index back to its corresponding source index.""" - if not proxy_index.isValid() or self.sourceModel() is None: - return QModelIndex() - - # Pass-through mode - if self._flattening_level == -1: - return self.sourceModel().index( - proxy_index.row(), - proxy_index.column(), - self.mapToSource(proxy_index.parent()), - ) - - # The internal pointer of a proxy index is our secret weapon. - # We use it to store the original source index for child items in a mixed hierarchy. - source_item_index = proxy_index.internalPointer() - - if source_item_index and source_item_index.isValid(): - # This is a child of a flattened item (e.g., a 'C' under a 'B' row). - # The pointer IS the source index. - return source_item_index - - # This is a top-level flattened item. We need to calculate the source index. - if 0 <= proxy_index.row() < len(self._source_map): - base_source_index = self._source_map[proxy_index.row()] - - # Traverse up the tree from the base source index to find the - # correct ancestor for the requested column. - source_index = base_source_index - for _ in range(self._flattening_level - proxy_index.column()): - source_index = source_index.parent() - return source_index - - return QModelIndex() - - def mapFromSource(self, source_index): - """Maps a source index to its corresponding proxy index.""" - if not source_index.isValid() or self.sourceModel() is None: - return QModelIndex() - - # Pass-through mode - if self._flattening_level == -1: - source_parent = source_index.parent() - proxy_parent = self.mapFromSource(source_parent) - return self.index(source_index.row(), source_index.column(), proxy_parent) - - # Find the item's ancestor that is at the flattening level. - ancestor_at_flattening_level = source_index - while ( - ancestor_at_flattening_level.isValid() - and ancestor_at_flattening_level.internalId() > 0 - ): # a bit of a heuristic check - parent = ancestor_at_flattening_level.parent() - if ( - not parent.isValid() - or parent == self.sourceModel().invisibleRootItem().index() - ): - break # Reached the top - if ( - len(self.sourceModel().data(parent, Qt.ItemDataRole.DisplayRole)) == 1 - ): # Heuristic for depth - break - ancestor_at_flattening_level = parent - - try: - # Find which row this ancestor corresponds to in our map. - proxy_row = self._source_map.index( - QPersistentModelIndex(ancestor_at_flattening_level) - ) - except ValueError: - return QModelIndex() # Not found in our map. - - # Now determine if this is a top-level item or a child. - if ancestor_at_flattening_level == source_index: - # It's a top-level item. Column is its depth. - return self.createIndex(proxy_row, source_index.column(), None) - else: - # It's a child of a top-level item. - return self.createIndex( - source_index.row(), source_index.column(), source_index - ) - - def rowCount(self, parent_proxy_index=QModelIndex()): - """Returns the number of rows under the given parent.""" - if self.sourceModel() is None: - return 0 - - # Pass-through mode - if self._flattening_level == -1: - return self.sourceModel().rowCount(self.mapToSource(parent_proxy_index)) - - if not parent_proxy_index.isValid(): - # Requesting number of top-level items. - return len(self._source_map) - - # Requesting number of children for an expanded item. - parent_source_index = self.mapToSource(parent_proxy_index) - return self.sourceModel().rowCount(parent_source_index) - - def columnCount(self, parent_proxy_index=QModelIndex()): - """Returns the number of columns.""" - if self.sourceModel() is None: - return 0 - - # Pass-through mode - if self._flattening_level == -1: - return self.sourceModel().columnCount(self.mapToSource(parent_proxy_index)) - - # We show one column for each level down to the flattening level. - return self._flattening_level + 1 - - def parent(self, proxy_child_index): - """Returns the parent of the given proxy index.""" - if not proxy_child_index.isValid() or self._flattening_level == -1: - return QModelIndex() - - source_child_index = self.mapToSource(proxy_child_index) - if not source_child_index.isValid(): - return QModelIndex() - - source_parent_index = source_child_index.parent() - - # Check if the source parent is one of our top-level items. - try: - # Find the row in our map that corresponds to the source parent. - proxy_row = self._source_map.index( - QPersistentModelIndex(source_parent_index) - ) - # If found, create a proxy index for it. This is the parent. - return self.createIndex(proxy_row, 0, None) # Parent is a top-level item - except ValueError: - # The parent is not a top-level item, so this child has no visible parent in the proxy. - return QModelIndex() - - def index(self, row, column, parent_proxy_index=QModelIndex()): - """Creates a proxy index for the given row, column, and parent.""" - if not self.hasIndex(row, column, parent_proxy_index): - return QModelIndex() - - # Pass-through mode - if self._flattening_level == -1: - source_parent_index = self.mapToSource(parent_proxy_index) - source_child_index = self.sourceModel().index( - row, column, source_parent_index - ) - return self.mapFromSource(source_child_index) - - if not parent_proxy_index.isValid(): - # Creating an index for a top-level item. - # We don't need to store anything in the internal pointer for these. - return self.createIndex(row, column, None) - else: - # Creating an index for a child of an expanded item. - # We store the child's *source model index* in the internal pointer. - # This is the key to linking back correctly in mapToSource. - parent_source_index = self.mapToSource(parent_proxy_index) - child_source_index = self.sourceModel().index(row, 0, parent_source_index) - return self.createIndex(row, column, child_source_index) - - def data(self, proxy_index, role=Qt.ItemDataRole.DisplayRole): - """Returns the data for a given proxy index.""" - if not proxy_index.isValid() or self.sourceModel() is None: - return None - - source_index = self.mapToSource(proxy_index) - if source_index.isValid(): - return self.sourceModel().data(source_index, role) - return None - - def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): - """Returns the header data.""" - if ( - orientation == Qt.Orientation.Horizontal - and role == Qt.ItemDataRole.DisplayRole - ): - if self._flattening_level == -1: - return self.sourceModel().headerData(section, orientation, role) - - if 0 <= section <= self._flattening_level: - # Create headers like 'A', 'B', 'C'... - return chr(ord("A") + section) - return super().headerData(section, orientation, role) - - -if __name__ == "__main__": - - class MainWindow(QMainWindow): - def __init__(self): - super().__init__() - self.setWindowTitle("Dynamic Flattening Proxy Model Demo") - self.setGeometry(100, 100, 1000, 700) - - # Main widget and layout - central_widget = QWidget() - self.setCentralWidget(central_widget) - layout = QVBoxLayout(central_widget) - - # 1. Create the source model - self.source_model = self._create_source_model() - - # 2. Create our custom flattening proxy - self.flattening_proxy = TreeFlatteningProxyModel() - self.flattening_proxy.setSourceModel(self.source_model) - - # 3. Create a standard sort/filter proxy to chain them - self.sort_filter_proxy = QSortFilterProxyModel() - # IMPORTANT: The source for the sort/filter proxy is our custom proxy - self.sort_filter_proxy.setSourceModel(self.flattening_proxy) - self.sort_filter_proxy.setFilterCaseSensitivity( - Qt.CaseSensitivity.CaseInsensitive - ) - self.sort_filter_proxy.setFilterKeyColumn(-1) # Filter on all columns - - # 4. Create the view - self.view = QTreeView() - # IMPORTANT: The view's model is the final proxy in the chain - self.view.setModel(self.sort_filter_proxy) - self.view.setSortingEnabled(True) - self.view.setAlternatingRowColors(True) - self.view.header().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - - # 5. Create controls - controls_layout = QVBoxLayout() - - # Control for filtering - filter_label = QLabel("Filter Table/Tree:") - self.filter_input = QLineEdit() - self.filter_input.textChanged.connect( - self.sort_filter_proxy.setFilterRegularExpression - ) - - # Control for changing flattening level - level_label = QLabel("Select Flattening Level:") - self.level_combo = QComboBox() - self.level_combo.addItems( - [ - "Disabled (Standard Tree View)", - "Level 0 (Rows of A)", - "Level 1 (Rows of B)", - "Level 2 (Rows of C)", - "Level 3 (Rows of D)", - "Level 4 (Rows of E - Full Flatten)", - ] - ) - self.level_combo.currentIndexChanged.connect(self.level_changed) - - controls_layout.addWidget(filter_label) - controls_layout.addWidget(self.filter_input) - controls_layout.addWidget(level_label) - controls_layout.addWidget(self.level_combo) - - layout.addLayout(controls_layout) - layout.addWidget(self.view) - - # Initialize view - self.level_changed(0) - - def _create_source_model(self): - """Creates and populates a 5-level deep QStandardItemModel.""" - model = QStandardItemModel() - model.setHorizontalHeaderLabels( - ["Data"] - ) # Only one column in the source model - - root = model.invisibleRootItem() - for i in range(3): # A - item_a = QStandardItem(f"A{i + 1}") - root.appendRow(item_a) - for j in range(2): # B - item_b = QStandardItem(f"B{i * 2 + j + 1}") - item_a.appendRow(item_b) - for k in range(2): # C - item_c = QStandardItem(f"C{i * 4 + j * 2 + k + 1}") - item_b.appendRow(item_c) - # Make D and E levels a bit irregular - for l in range(1 + (j % 2)): # D - item_d = QStandardItem(f"D{i * 8 + j * 4 + k * 2 + l + 1}") - item_c.appendRow(item_d) - for m in range(2 + (k % 2)): # E - item_e = QStandardItem( - f"E{i * 16 + j * 8 + k * 4 + l * 2 + m + 1}" - ) - item_d.appendRow(item_e) - return model - - def level_changed(self, index): - """Slot to handle the user changing the flattening level.""" - level = index - 1 - self.flattening_proxy.setFlatteningLevel(level) - - # Adjust view properties for better user experience - if level == -1: - # Standard tree view - self.view.header().setVisible(False) - self.view.expandAll() - else: - # Table-like view - self.view.header().setVisible(True) - self.view.expandAll() # Expand to see children in mixed-hierarchy views - self.view.header().setSectionResizeMode( - QHeaderView.ResizeMode.ResizeToContents - ) - self.view.header().setStretchLastSection(True) - - app = QApplication(sys.argv) - window = MainWindow() - window.show() - sys.exit(app.exec()) From e781e5003ac17a7396baabece03b16010b6ea1a4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 3 Jul 2025 20:31:51 -0400 Subject: [PATCH 42/70] better resizing --- examples/config_groups_editor.py | 1 + src/pymmcore_widgets/_icons.py | 5 + src/pymmcore_widgets/_models/_flat_chatgpt.py | 514 ++++++++++++++++++ .../_models/_py_config_model.py | 20 +- .../_models/_q_device_prop_model.py | 261 +++++---- .../_views/_config_groups_editor.py | 239 +++++--- .../_views/_device_property_selector.py | 270 +++++++++ 7 files changed, 1104 insertions(+), 206 deletions(-) create mode 100644 src/pymmcore_widgets/_models/_flat_chatgpt.py create mode 100644 src/pymmcore_widgets/config_presets/_views/_device_property_selector.py diff --git a/examples/config_groups_editor.py b/examples/config_groups_editor.py index 8afcedeb4..3a0c7cc97 100644 --- a/examples/config_groups_editor.py +++ b/examples/config_groups_editor.py @@ -12,5 +12,6 @@ cfg = ConfigGroupsEditor.create_from_core(core) cfg.setCurrentPreset("Channel", "FITC") cfg.show() +cfg.resize(1200, 800) app.exec() diff --git a/src/pymmcore_widgets/_icons.py b/src/pymmcore_widgets/_icons.py index 24036cae6..b764af6c7 100644 --- a/src/pymmcore_widgets/_icons.py +++ b/src/pymmcore_widgets/_icons.py @@ -23,6 +23,11 @@ DeviceType.Serial: "mdi:serial-port", } +PROPERTY_FLAG_ICON: dict[str, str] = { + "read-only": "mdi:lock-outline", + "pre-init": "mdi:alpha-p-box-outline", +} + def get_device_icon( device_type_or_name: DeviceType | str, color: str = "gray" diff --git a/src/pymmcore_widgets/_models/_flat_chatgpt.py b/src/pymmcore_widgets/_models/_flat_chatgpt.py new file mode 100644 index 000000000..607b0d059 --- /dev/null +++ b/src/pymmcore_widgets/_models/_flat_chatgpt.py @@ -0,0 +1,514 @@ +from __future__ import annotations + +import sys +from typing import NamedTuple + +from qtpy import QtCore, QtWidgets +from qtpy.QtCore import QAbstractProxyModel, QModelIndex, QPersistentModelIndex, Qt +from qtpy.QtGui import QStandardItem, QStandardItemModel +from qtpy.QtWidgets import ( + QApplication, + QComboBox, + QHBoxLayout, + QHeaderView, + QLabel, + QTreeView, + QVBoxLayout, + QWidget, +) + + +class RowInfo(NamedTuple): + leaf: QPersistentModelIndex + ancestors: list[QPersistentModelIndex] + + +class FlattenProxyModel(QAbstractProxyModel): + def __init__(self, level: int = 0, parent: QtCore.QObject | None = None): + super().__init__(parent) + self._level = level + self._rows: list[RowInfo] = [] + self._src2row: dict[QPersistentModelIndex, int] = {} + self._mixed = False # Whether to show mixed hierarchy + self._child_cache: dict[ + QPersistentModelIndex, QPersistentModelIndex + ] = {} # Cache for child indices + + # ------------------------------------------------------------------ + # Debug helpers + # ------------------------------------------------------------------ + def _validate(self, idx: QModelIndex) -> None: + """Verify that every index we emit stores either None or a QPersistentModelIndex.""" + if not idx.isValid(): + return + ip = idx.internalPointer() + if ip is not None and not isinstance(ip, QPersistentModelIndex): + raise RuntimeError(f"Bad internalPointer detected: {ip!r}") + + def _ci(self, row: int, col: int, ptr) -> QModelIndex: + """CreateIndex + validation wrapper.""" + idx = self.createIndex(row, col, ptr) + self._validate(idx) + return idx + + def setLevel(self, level: int): + self._level = level + self._rebuild() + + def setMixed(self, mixed: bool): + self._mixed = mixed + self._rebuild() + + def _rebuild(self): + self.beginResetModel() + self._rows.clear() + self._src2row.clear() + self._child_cache.clear() + self._child_cache.clear() + + if not self.sourceModel(): + self.endResetModel() + return + + if self._level < 0: + # Pass-through mode + self.endResetModel() + return + + # Build flattened rows + self._traverse(QModelIndex(), []) + self.endResetModel() + + def _traverse(self, parent: QModelIndex, ancestors: list[QPersistentModelIndex]): + for r in range(self.sourceModel().rowCount(parent)): + child = self.sourceModel().index(r, 0, parent) + child_ancestors = [*ancestors, QPersistentModelIndex(child)] + + if len(child_ancestors) - 1 == self._level: + # This is a leaf at the desired level + row_info = RowInfo(QPersistentModelIndex(child), child_ancestors) + self._src2row[QPersistentModelIndex(child)] = len(self._rows) + self._rows.append(row_info) + elif len(child_ancestors) - 1 < self._level: + # Keep going deeper + self._traverse(child, child_ancestors) + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + if not self.sourceModel(): + return 0 + + if self._level < 0: + # Pass-through mode + return self.sourceModel().rowCount(self.mapToSource(parent)) + + if not parent.isValid(): + # Top-level: return number of flattened rows + return len(self._rows) + + # Mixed hierarchy: children of flattened rows + if self._mixed and not parent.internalPointer(): + # Children of a flattened row + try: + rowinfo = self._rows[parent.row()] + src_parent = QModelIndex(rowinfo.leaf) + return self.sourceModel().rowCount(src_parent) + except: + return 0 + + # Grandchildren or deeper + # New: if parent.internalPointer() is a QPersistentModelIndex + if self._mixed and isinstance(parent.internalPointer(), QPersistentModelIndex): + src_parent_persistent = parent.internalPointer() + if src_parent_persistent.isValid(): + src_parent = QModelIndex(src_parent_persistent) + return self.sourceModel().rowCount(src_parent) + + return 0 + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: + if not self.sourceModel(): + return 0 + if self._level < 0: + # Pass-through mode + return self.sourceModel().columnCount() + # In flattened mode, show columns for each level in the hierarchy + return self._level + 1 + + def hasChildren(self, parent: QModelIndex = QModelIndex()) -> bool: + if not self.sourceModel(): + return False + + if self._level < 0: + # Pass-through mode + src_index = self.mapToSource(parent) + return self.sourceModel().hasChildren(src_index) + + if not parent.isValid(): + # Top-level always has children (the flattened rows) + return len(self._rows) > 0 + + # Mixed hierarchy: check if flattened rows have children + if self._mixed and not parent.internalPointer(): + try: + if parent.row() < len(self._rows): + rowinfo = self._rows[parent.row()] + # Recreate QModelIndex from QPersistentModelIndex using its data + if rowinfo.leaf.isValid(): + # Get the parent of the leaf to recreate the hierarchy + persistent_parent = ( + rowinfo.leaf.parent() + if rowinfo.leaf.parent().isValid() + else QModelIndex() + ) + src_index = self.sourceModel().index( + rowinfo.leaf.row(), rowinfo.leaf.column(), persistent_parent + ) + return self.sourceModel().hasChildren(src_index) + except Exception: + pass + return False + + # Grandchildren or deeper + # New: handle QPersistentModelIndex stored in internalPointer + if self._mixed and isinstance(parent.internalPointer(), QPersistentModelIndex): + src_parent_persistent = parent.internalPointer() + if src_parent_persistent.isValid(): + src_parent = QModelIndex(src_parent_persistent) + return self.sourceModel().hasChildren(src_parent) + return False + + return False + + def index( + self, row: int, col: int, parent: QModelIndex = QModelIndex() + ) -> QModelIndex: + if row < 0 or col < 0: + return QModelIndex() + + if not self.sourceModel(): + return QModelIndex() + + # Pass-through mode + if self._level < 0: + try: + src_parent = self.mapToSource(parent) + if not src_parent.isValid() and parent.isValid(): + return QModelIndex() + return self._ci(row, col, None) + except Exception: + return QModelIndex() + + # Top-level flattened row + if not parent.isValid(): + if row >= len(self._rows) or col >= self.columnCount(): + return QModelIndex() + return self._ci(row, col, None) + + # Mixed hierarchy disabled - no children + if not self._mixed: + return QModelIndex() + + # Mixed hierarchy: children of flattened rows + if parent.internalPointer() is None: + if parent.row() >= len(self._rows): + return QModelIndex() + rowinfo = self._rows[parent.row()] + if not rowinfo.leaf.isValid(): + return QModelIndex() + src_parent = rowinfo.leaf.model().index( + rowinfo.leaf.row(), + rowinfo.leaf.column(), + QModelIndex(), + ) + if col != 0: + return QModelIndex() + if row >= self.sourceModel().rowCount(src_parent): + return QModelIndex() + src_child = self.sourceModel().index(row, 0, src_parent) + if not src_child.isValid(): + return QModelIndex() + # Use the persistent source index directly as internal pointer + persistent = QPersistentModelIndex(src_child) + self._child_cache[persistent] = persistent + return self._ci(row, col, persistent) + + # Grandchildren or deeper + elif parent.internalPointer() is not None: + # Use the persistent source index directly as internal pointer + persistent = QPersistentModelIndex( + self.sourceModel().index(row, 0, QModelIndex(parent.internalPointer())) + ) + src_parent_persistent = parent.internalPointer() + if ( + not isinstance(src_parent_persistent, QPersistentModelIndex) + or not src_parent_persistent.isValid() + ): + return QModelIndex() + src_parent = QModelIndex(src_parent_persistent) + if col != 0: + return QModelIndex() + if row >= self.sourceModel().rowCount(src_parent): + return QModelIndex() + src_child = self.sourceModel().index(row, 0, src_parent) + if not src_child.isValid(): + return QModelIndex() + persistent = QPersistentModelIndex(src_child) + self._child_cache[persistent] = persistent + return self._ci(row, col, persistent) + + return QModelIndex() + + def parent(self, child: QModelIndex) -> QModelIndex: + if not child.isValid() or self._level < 0: + return QModelIndex() + + # If this is a top-level flattened row, it has no parent + if child.internalPointer() is None: + return QModelIndex() + + # If this is a mixed hierarchy child + if self._mixed and isinstance(child.internalPointer(), QPersistentModelIndex): + child_persistent = child.internalPointer() + if not child_persistent.isValid(): + return QModelIndex() + + src_child = QModelIndex(child_persistent) + src_parent = src_child.parent() + + psrc_parent = QPersistentModelIndex(src_parent) + if psrc_parent in self._src2row: + return self._ci(self._src2row[psrc_parent], 0, None) + + if src_parent.isValid(): + parent_persistent = QPersistentModelIndex(src_parent) + self._child_cache[parent_persistent] = parent_persistent + return self._ci(src_parent.row(), 0, parent_persistent) + + return QModelIndex() + + return QModelIndex() + + def mapToSource(self, proxy: QModelIndex) -> QModelIndex: + if not proxy.isValid() or not self.sourceModel(): + return QModelIndex() + + if self._level < 0: + # Pass-through mode + try: + return self.sourceModel().index( + proxy.row(), proxy.column(), QModelIndex() + ) + except Exception: + return QModelIndex() + + # Top-level flattened row showing hierarchical path + if proxy.internalPointer() is None: + if proxy.row() >= len(self._rows): + return QModelIndex() + row_info = self._rows[proxy.row()] + # Return the ancestor at the requested column level + if proxy.column() < len(row_info.ancestors): + ancestor = row_info.ancestors[proxy.column()] + if isinstance(ancestor, QPersistentModelIndex) and ancestor.isValid(): + return QModelIndex(ancestor) + return QModelIndex() + + # Mixed hierarchy child + if self._mixed and isinstance(proxy.internalPointer(), QPersistentModelIndex): + cached_persistent = proxy.internalPointer() + if cached_persistent.isValid(): + return QModelIndex(cached_persistent) + return QModelIndex() + + return QModelIndex() + + def mapFromSource(self, src: QModelIndex) -> QModelIndex: + if not src.isValid() or not self.sourceModel(): + return QModelIndex() + + if self._level < 0: + # Pass-through mode + try: + return self._ci(src.row(), src.column(), None) + except: + return QModelIndex() + + psrc = QPersistentModelIndex(src) + + # Check if this is a flattened row + if (row := self._src2row.get(psrc)) is not None: + return self.index(row, 0) + + # Check if this is in the ancestors of any flattened row + for r, info in enumerate(self._rows): + if psrc in info.ancestors: + col = info.ancestors.index(psrc) + return self.index(r, col) + + if self._mixed: + psrc_child = QPersistentModelIndex(src) + self._child_cache[psrc_child] = psrc_child + return self._ci(src.row(), 0, psrc_child) + + return QModelIndex() + + def data(self, idx: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): + if not idx.isValid() or not self.sourceModel(): + return None + try: + return self.sourceModel().data(self.mapToSource(idx), role) + except: + return None + + def headerData( + self, + section: int, + orient: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ): + if not self.sourceModel(): + return None + + if ( + orient == Qt.Orientation.Horizontal + and role == Qt.ItemDataRole.DisplayRole + and self._level >= 0 + ): + if section == 0: + return "A" + elif section == 1: + return "B" + elif section == 2: + return "C" + elif section == 3: + return "D" + elif section == 4: + return "E" + else: + return f"Level {section}" + + return self.sourceModel().headerData(section, orient, role) + + def setSourceModel(self, model): + if self.sourceModel(): + try: + self.sourceModel().modelReset.disconnect(self._rebuild) + except: + pass + super().setSourceModel(model) + if model: + model.modelReset.connect(self._rebuild) + self._rebuild() + + +# Demo window +class DemoWindow(QWidget): + def __init__(self): + super().__init__() + self.setWindowTitle("Tree Flatten Demo") + self.resize(1200, 600) + + # Create source model + self.src_model = QStandardItemModel() + self._populate_model() + + # Create proxy model + self.flatten = FlattenProxyModel(level=3) + self.flatten.setSourceModel(self.src_model) + + from qtpy.QtTest import QAbstractItemModelTester + + QAbstractItemModelTester( + self.flatten, + QAbstractItemModelTester.FailureReportingMode.Fatal, + self, + ) + + # Create layout + layout = QVBoxLayout(self) + + # Controls + controls = QHBoxLayout() + + # Level selector + controls.addWidget(QLabel("Flatten Level:")) + self.level_combo = QComboBox() + self.level_combo.addItems( + ["Pass-through", "Level 0 - A", "Level 1 - B", "Level 2 - C", "Level 3 - D"] + ) + self.level_combo.setCurrentIndex(4) # Level 3 - D + self.level_combo.currentIndexChanged.connect(self._on_level_changed) + controls.addWidget(self.level_combo) + + # Mixed hierarchy checkbox + self.mixed_check = QtWidgets.QCheckBox("Mixed Hierarchy") + self.mixed_check.setChecked(True) # Try with True to test + self.mixed_check.toggled.connect(self._on_mixed_changed) + controls.addWidget(self.mixed_check) + + controls.addStretch() + layout.addLayout(controls) + + # Views + views_layout = QHBoxLayout() + + # Original view + orig_view = QTreeView() + orig_view.setModel(self.src_model) + orig_view.expandAll() + orig_view.header().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + views_layout.addWidget(orig_view) + + # Flattened view + flat_view = QTreeView() + flat_view.setModel(self.flatten) + flat_view.expandAll() + flat_view.header().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + flat_view.header().setStretchLastSection(True) + views_layout.addWidget(flat_view) + + layout.addLayout(views_layout) + + # Set initial state + self._on_level_changed(2) # Level 2 - B + self._on_mixed_changed(True) # Try with mixed hierarchy + + def _populate_model(self): + root = self.src_model.invisibleRootItem() + d = 2 + for i in range(d): + item_a = QStandardItem(f"A{i}") + root.appendRow(item_a) + for j in range(d): + item_b = QStandardItem(f"B{j}") + item_a.appendRow(item_b) + for k in range(d): + item_c = QStandardItem(f"C{k}") + item_b.appendRow(item_c) + # for l in range(d): + # item_d = QStandardItem(f"D{l}") + # item_c.appendRow(item_d) + # for m in range(d): + # item_e = QStandardItem(f"E{m}") + # item_d.appendRow(item_e) + + def _parse_level_from_combo(self, index: int) -> int: + if index == 0: + return -1 # Pass-through + return index - 1 + + def _on_level_changed(self, index: int): + level = self._parse_level_from_combo(index) + self.flatten.setLevel(level) + + def _on_mixed_changed(self, checked: bool): + self.flatten.setMixed(checked) + + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = DemoWindow() + + window.show() + # sys.exit(app.exec()) + app.processEvents() diff --git a/src/pymmcore_widgets/_models/_py_config_model.py b/src/pymmcore_widgets/_models/_py_config_model.py index 640e4d519..23c3bf440 100644 --- a/src/pymmcore_widgets/_models/_py_config_model.py +++ b/src/pymmcore_widgets/_models/_py_config_model.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, ClassVar from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator -from pymmcore_plus import DeviceType, PropertyType +from pymmcore_plus import DeviceType, Keyword, PropertyType from typing_extensions import TypeAlias if TYPE_CHECKING: @@ -85,6 +85,13 @@ 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) @@ -93,6 +100,17 @@ 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) -> str | None: + """Return an iconify key for the device type.""" + from pymmcore_widgets._icons import PROPERTY_FLAG_ICON + + if self.is_read_only: + return PROPERTY_FLAG_ICON["read-only"] + elif self.is_pre_init: + return PROPERTY_FLAG_ICON["pre-init"] + return None + @model_validator(mode="before") @classmethod def _validate_input(cls, values: Any) -> Any: diff --git a/src/pymmcore_widgets/_models/_q_device_prop_model.py b/src/pymmcore_widgets/_models/_q_device_prop_model.py index ce0b40546..2b708a3d4 100644 --- a/src/pymmcore_widgets/_models/_q_device_prop_model.py +++ b/src/pymmcore_widgets/_models/_q_device_prop_model.py @@ -3,13 +3,8 @@ from copy import deepcopy from typing import TYPE_CHECKING, Any, cast -from pymmcore_plus import PropertyType from qtpy.QtCore import ( - QAbstractItemModel, - QAbstractProxyModel, QModelIndex, - QObject, - QPersistentModelIndex, Qt, ) from qtpy.QtGui import QBrush, QFont, QIcon @@ -49,6 +44,42 @@ def columnCount(self, _parent: QModelIndex | None = None) -> int: # 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) @@ -56,57 +87,19 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A if node is self._root: return None - if index.column() == 1: - if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - if isinstance(device := node.payload, Device): - return device.type.name - elif isinstance(setting := node.payload, DevicePropertySetting): - return setting.property_type.name - elif role == Qt.ItemDataRole.DecorationRole: - if isinstance(device := node.payload, Device): - if icon := device.iconify_key: - return QIconifyIcon(icon, color="gray").pixmap(16, 16) - return QIcon.fromTheme("emblem-system") # pragma: no cover - elif isinstance(setting := node.payload, DevicePropertySetting): - if setting.is_read_only: - return QIcon.fromTheme("lock") - elif setting.is_pre_init: - return QIconifyIcon( - "ph:letter-circle-p-duotone", color="gray" - ).pixmap(16, 16) - elif setting.property_type == PropertyType.String: - return QIconifyIcon("mdi:code-string", color="gray").pixmap( - 16, 16 - ) - elif setting.property_type in ( - PropertyType.Integer, - PropertyType.Float, - ): - return QIconifyIcon("mdi:numbers", color="gray").pixmap(16, 16) - return - + col = index.column() # Qt.ItemDataRole.UserRole => return the original python object if role == Qt.ItemDataRole.UserRole: return node.payload + elif role == Qt.ItemDataRole.CheckStateRole and col == 0: + if isinstance(setting := node.payload, DevicePropertySetting): + return node.check_state + if isinstance(device := node.payload, Device): - if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - return device.name + return self._get_device_data(device, col, role) elif isinstance(setting := node.payload, DevicePropertySetting): - if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - return setting.property_name - elif role == Qt.ItemDataRole.FontRole: - if setting.is_read_only: - font = QFont() - font.setItalic(True) - return font - elif role == Qt.ItemDataRole.CheckStateRole: - if not (setting.is_read_only or setting.is_pre_init): - return node.check_state - elif role == Qt.ItemDataRole.ForegroundRole: - if setting.is_read_only or setting.is_pre_init: - return QBrush(Qt.GlobalColor.gray) - + return self._get_prop_data(setting, col, role) return None def setData( @@ -152,87 +145,87 @@ def get_devices(self) -> list[Device]: return deepcopy([cast("Device", n.payload) for n in self._root.children]) -class FlatPropertyModel(QAbstractProxyModel): - """Presents every *leaf* of an arbitrary tree model as a top-level row.""" - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent) - self._leaves: list[QPersistentModelIndex] = [] - - def index(self, row: int, column: int, parent: QModelIndex = ...) -> QModelIndex: - if not (sm := self.sourceModel()): - return QModelIndex() - return sm.index(row, column, parent) - - # -------------------------------------------------------------------------------- - # mandatory proxy plumbing - # -------------------------------------------------------------------------------- - def setSourceModel(self, source_model: QAbstractItemModel | None) -> None: - super().setSourceModel(source_model) - self._rebuild() - # keep list in sync with structural changes - source_model.rowsInserted.connect(self._rebuild) - source_model.rowsRemoved.connect(self._rebuild) - source_model.modelReset.connect(self._rebuild) - - # map source ↔ proxy ----------------------------------------------------- - def mapToSource(self, proxy_index: QModelIndex) -> QModelIndex: - return ( - QModelIndex(self._leaves[proxy_index.row()]) - if proxy_index.isValid() - else QModelIndex() - ) - - def mapFromSource(self, source_index: QModelIndex) -> QModelIndex: - try: - row = self._leaves.index(QPersistentModelIndex(source_index)) - return self.createIndex(row, source_index.column()) - except ValueError: - return QModelIndex() - - # shape ------------------------------------------------------------------ - def rowCount(self, _parent: QModelIndex = NULL_INDEX) -> int: - return len(self._leaves) - - def columnCount(self, parent: QModelIndex = NULL_INDEX) -> int: - if sm := self.sourceModel(): - return sm.columnCount(self.mapToSource(parent)) - return 0 - - # data, flags, setData simply delegate to the source -------------------- - def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: - if sm := self.sourceModel(): - return sm.data(self.mapToSource(index), role) - return None - - def flags(self, index: QModelIndex) -> Qt.ItemFlag: - if sm := self.sourceModel(): - return sm.flags(self.mapToSource(index)) - return Qt.ItemFlag.NoItemFlags - - def setData( - self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole - ) -> bool: - if sm := self.sourceModel(): - return sm.setData(self.mapToSource(index), value, role) - return False - - # helpers ---------------------------------------------------------------- - def _rebuild(self) -> None: - """Cache every leaf `QModelIndex` of the tree.""" - if not (sm := self.sourceModel()): - return - self.beginResetModel() - self._leaves.clear() - - def walk(parent: QModelIndex) -> None: - rows = sm.rowCount(parent) - for r in range(rows): - idx = sm.index(r, 0, parent) - if sm.rowCount(idx): # branch - walk(idx) - else: # leaf - self._leaves.append(QPersistentModelIndex(idx)) - - walk(QModelIndex()) - self.endResetModel() +# class FlatPropertyModel(QAbstractProxyModel): +# """Presents every *leaf* of an arbitrary tree model as a top-level row.""" + +# def __init__(self, parent: QObject | None = None) -> None: +# super().__init__(parent) +# self._leaves: list[QPersistentModelIndex] = [] + +# def index(self, row: int, column: int, parent: QModelIndex = ...) -> QModelIndex: +# if not (sm := self.sourceModel()): +# return QModelIndex() +# return sm.index(row, column, parent) + +# # -------------------------------------------------------------------------------- +# # mandatory proxy plumbing +# # -------------------------------------------------------------------------------- +# def setSourceModel(self, source_model: QAbstractItemModel | None) -> None: +# super().setSourceModel(source_model) +# self._rebuild() +# # keep list in sync with structural changes +# source_model.rowsInserted.connect(self._rebuild) +# source_model.rowsRemoved.connect(self._rebuild) +# source_model.modelReset.connect(self._rebuild) + +# # map source ↔ proxy ----------------------------------------------------- +# def mapToSource(self, proxy_index: QModelIndex) -> QModelIndex: +# return ( +# QModelIndex(self._leaves[proxy_index.row()]) +# if proxy_index.isValid() +# else QModelIndex() +# ) + +# def mapFromSource(self, source_index: QModelIndex) -> QModelIndex: +# try: +# row = self._leaves.index(QPersistentModelIndex(source_index)) +# return self.createIndex(row, source_index.column()) +# except ValueError: +# return QModelIndex() + +# # shape ------------------------------------------------------------------ +# def rowCount(self, _parent: QModelIndex = NULL_INDEX) -> int: +# return len(self._leaves) + +# def columnCount(self, parent: QModelIndex = NULL_INDEX) -> int: +# if sm := self.sourceModel(): +# return sm.columnCount(self.mapToSource(parent)) +# return 0 + +# # data, flags, setData simply delegate to the source -------------------- +# def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: +# if sm := self.sourceModel(): +# return sm.data(self.mapToSource(index), role) +# return None + +# def flags(self, index: QModelIndex) -> Qt.ItemFlag: +# if sm := self.sourceModel(): +# return sm.flags(self.mapToSource(index)) +# return Qt.ItemFlag.NoItemFlags + +# def setData( +# self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole +# ) -> bool: +# if sm := self.sourceModel(): +# return sm.setData(self.mapToSource(index), value, role) +# return False + +# # helpers ---------------------------------------------------------------- +# def _rebuild(self) -> None: +# """Cache every leaf `QModelIndex` of the tree.""" +# if not (sm := self.sourceModel()): +# return +# self.beginResetModel() +# self._leaves.clear() + +# def walk(parent: QModelIndex) -> None: +# rows = sm.rowCount(parent) +# for r in range(rows): +# idx = sm.index(r, 0, parent) +# if sm.rowCount(idx): # branch +# walk(idx) +# else: # leaf +# self._leaves.append(QPersistentModelIndex(idx)) + +# walk(QModelIndex()) +# self.endResetModel() diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index 7989c0d31..40e62e0ad 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -1,25 +1,24 @@ from __future__ import annotations +from contextlib import contextmanager +from enum import Enum, auto from typing import TYPE_CHECKING, cast -from pymmcore_plus import DeviceType -from qtpy.QtCore import QModelIndex, QSortFilterProxyModel, Qt, Signal +from qtpy.QtCore import QModelIndex, Qt, Signal from qtpy.QtWidgets import ( QListView, + QSizePolicy, QSplitter, QToolBar, - QTreeView, QVBoxLayout, QWidget, ) +from superqt import QIconifyIcon from pymmcore_widgets._models import ( ConfigGroup, ConfigPreset, - Device, - DevicePropertySetting, QConfigGroupsModel, - QDevicePropertyModel, get_config_groups, get_loaded_devices, ) @@ -27,14 +26,22 @@ ConfigPresetsTable, ) +from ._device_property_selector import DevicePropertySelector + if TYPE_CHECKING: - from collections.abc import Iterable, Sequence + from collections.abc import Iterable, Iterator, Sequence from pymmcore_plus import CMMCorePlus + from PyQt6.QtGui import QAction from pymmcore_widgets._models._base_tree_model import _Node else: - pass + from qtpy.QtGui import QAction + + +class LayoutMode(Enum): + FAVOR_PRESETS = auto() # preset-table full width at bottom + FAVOR_PROPERTIES = auto() # prop-selector full height at right # ----------------------------------------------------------------------------- @@ -98,6 +105,15 @@ def __init__(self, parent: QWidget | None = None) -> None: self._tb.addAction("Remove") self._tb.addAction("Duplicate") + spacer = QWidget(self._tb) + spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self._tb.addWidget(spacer) + + icon = QIconifyIcon("mdi:toggle-switch-off-outline") + icon.addKey("mdi:toggle-switch-outline", state=QIconifyIcon.State.On) + if act := self._tb.addAction(icon, "Wide Layout", self.setLayoutMode): + act.setCheckable(True) + self.group_list = QListView(self) self.group_list.setModel(self._model) self.group_list.setSelectionMode(QListView.SelectionMode.SingleSelection) @@ -111,29 +127,15 @@ def __init__(self, parent: QWidget | None = None) -> None: self._preset_table = ConfigPresetsTable(self) self._preset_table.setModel(self._model) self._preset_table.setGroup("Channel") - # layout ------------------------------------------------------------ - - top = QSplitter(Qt.Orientation.Horizontal, self) - top.addWidget(self.group_list) - top.addWidget(self.preset_list) - top.addWidget(self._prop_selector) - top_splitter = QSplitter(Qt.Orientation.Horizontal, self) - top_splitter.setHandleWidth(1) - top_splitter.setChildrenCollapsible(False) - top_splitter.addWidget(top) - # top_splitter.addWidget(preset_box) - - main_splitter = QSplitter(Qt.Orientation.Vertical, self) - main_splitter.setHandleWidth(1) - main_splitter.addWidget(top_splitter) - main_splitter.addWidget(self._preset_table) + # layout ------------------------------------------------------------ + self._current_mode: LayoutMode = LayoutMode.FAVOR_PRESETS layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self._tb) - layout.addWidget(main_splitter) + self.setLayoutMode(mode=LayoutMode.FAVOR_PRESETS) # signals ------------------------------------------------------------ @@ -254,51 +256,146 @@ def _our_preset_changed_by_range( preset = cast("ConfigPreset", node.payload) return preset + def _build_layout(self, mode: LayoutMode) -> QSplitter: + """Return a new top-level splitter for the requested layout.""" + if mode is LayoutMode.FAVOR_PRESETS: + # ┌───────────────┬───────────────┬───────────────┐ + # │ groups │ presets │ props │ + # ├───────────────┴───────────────┴───────────────┤ + # │ presets-table │ + # └───────────────────────────────────────────────┘ + top_splitter = QSplitter(Qt.Orientation.Horizontal) + top_splitter.addWidget(self.group_list) + top_splitter.addWidget(self.preset_list) + top_splitter.addWidget(self._prop_selector) + top_splitter.setStretchFactor(2, 1) + + main = QSplitter(Qt.Orientation.Vertical) + main.setChildrenCollapsible(False) + main.addWidget(top_splitter) + main.addWidget(self._preset_table) + return main + + if mode is LayoutMode.FAVOR_PROPERTIES: + # ┌───────────────┬───────────────┬───────────────┐ + # │ groups │ presets │ │ + # ├───────────────┴───────────────┤ props │ + # │ presets-table │ │ + # └───────────────────────────────┴───────────────┘ + lists_splitter = QSplitter(Qt.Orientation.Horizontal) + lists_splitter.addWidget(self.group_list) + lists_splitter.addWidget(self.preset_list) + + left_splitter = QSplitter(Qt.Orientation.Vertical) + left_splitter.addWidget(lists_splitter) + left_splitter.addWidget(self._preset_table) + + main = QSplitter(Qt.Orientation.Horizontal) + main.setChildrenCollapsible(False) + main.addWidget(left_splitter) + main.addWidget(self._prop_selector) + main.setStretchFactor(2, 1) + return main + + raise ValueError(f"Unknown layout mode: {mode}") + + def setLayoutMode(self, mode: LayoutMode | None = None) -> None: + """Slot connected to the toolbar action.""" + if not (layout := self.layout()): + return -# TODO: Allow GUI control of parameters -class DeviceTypeFilter(QSortFilterProxyModel): - def __init__(self, allowed: set[DeviceType], parent: QWidget | None = None) -> None: - super().__init__(parent) - self.allowed = allowed # e.g. {"Camera", "Shutter"} - - def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool: - if sm := self.sourceModel(): - idx = sm.index(source_row, 0, source_parent) - if DeviceType.Any in self.allowed: - return True - data = idx.data(Qt.ItemDataRole.UserRole) - if isinstance(obj := data, Device): - return obj.type in self.allowed - elif isinstance(obj, DevicePropertySetting): - if obj.is_pre_init or obj.is_read_only: - return False - return True - - -class DevicePropertySelector(QWidget): - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - - tree = QTreeView(self) - - self._model = QDevicePropertyModel() - # flat_proxy = FlatPropertyModel() - # proxy = DeviceTypeFilter(allowed={DeviceType.Camera}, parent=self) - # proxy.setSourceModel(self._model) - # flat_proxy.setSourceModel(self._model) - - tree.setModel(self._model) - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(tree) - - def clear(self) -> None: - """Clear the current selection.""" - # self.table.setValue([]) - - def setChecked(self, settings: Iterable[tuple[str, str, str]]) -> None: - """Set the checked state of the properties based on the given settings.""" - # self.table.setValue(settings) + if mode is None: + if not isinstance(sender := self.sender(), QAction): + return + mode = ( + LayoutMode.FAVOR_PROPERTIES + if sender.isChecked() + else LayoutMode.FAVOR_PRESETS + ) + else: + mode = LayoutMode(mode) + + sizes = self._get_list_sizes() # get splitter sizes if available + + with updates_disabled(self): + if isinstance( + cur_splitter := getattr(self, "_main_splitter", None), QSplitter + ): + layout.removeWidget(cur_splitter) + cur_splitter.setParent(None) + cur_splitter.deleteLater() + + # build and insert the replacement + self._main_splitter = new_splitter = self._build_layout(mode) + layout.addWidget(new_splitter) + if sizes is not None: + self._set_list_sizes(*sizes, main_splitter=new_splitter) + self._current_mode = mode + + def _get_list_sizes(self) -> tuple[list[int], list[int], list[int]] | None: + main_splitter = getattr(self, "_main_splitter", None) + if not isinstance(main_splitter, QSplitter): + return None - def setAvailableDevices(self, devices: Iterable[Device]) -> None: - self._model.set_devices(devices) + # FAVOR_PRESETS + if main_splitter.orientation() == Qt.Orientation.Vertical and isinstance( + top_splitter := main_splitter.widget(0), QSplitter + ): + list_widths = top_splitter.sizes()[:2] + left_heights = main_splitter.sizes() + sum_width = sum(list_widths) + main_splitter.handleWidth() + full_width = top_splitter.size().width() + return list_widths, left_heights, [sum_width, full_width - sum_width] + + # FAVOR_PROPERTIES + elif ( + main_splitter.orientation() == Qt.Orientation.Horizontal + and isinstance(left_splitter := main_splitter.widget(0), QSplitter) + and isinstance(lists_splitter := left_splitter.widget(0), QSplitter) + ): + list_widths = lists_splitter.sizes() + left_heights = left_splitter.sizes() + full_width = main_splitter.size().width() + return ( + list_widths, + left_heights, + [*list_widths, full_width - sum(list_widths)], + ) + + return None + + def _set_list_sizes( + self, + list_widths: list[int], + left_heights: list[int], + top_splits: list[int], + main_splitter: QSplitter, + ) -> None: + """Set the saved sizes of the group and preset lists.""" + if main_splitter.orientation() == Qt.Orientation.Vertical and isinstance( + top_splitter := main_splitter.widget(0), QSplitter + ): + top_splitter.setSizes(top_splits) + main_splitter.setSizes(left_heights) + + # FAVOR_PROPERTIES + elif ( + main_splitter.orientation() == Qt.Orientation.Horizontal + and isinstance(left_splitter := main_splitter.widget(0), QSplitter) + and isinstance(lists_splitter := left_splitter.widget(0), QSplitter) + ): + main_splitter.setSizes(top_splits) + lists_splitter.setSizes(list_widths) + left_splitter.setSizes(left_heights) + + +@contextmanager +def updates_disabled(widget: QWidget) -> Iterator[None]: + """Check if updates are currently disabled for the widget.""" + """Context manager to temporarily disable updates for a widget.""" + was_enabled = widget.updatesEnabled() + widget.setUpdatesEnabled(False) + try: + yield + finally: + widget.setUpdatesEnabled(was_enabled) diff --git a/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py new file mode 100644 index 000000000..806504dd9 --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from pymmcore_plus import DeviceType +from qtpy.QtCore import QModelIndex, QSize, QSortFilterProxyModel, Qt, Signal +from qtpy.QtWidgets import ( + QLineEdit, + QSizePolicy, + QToolBar, + QTreeView, + QVBoxLayout, + QWidget, +) +from superqt import QIconifyIcon + +from pymmcore_widgets._icons import DEVICE_TYPE_ICON, PROPERTY_FLAG_ICON +from pymmcore_widgets._models import Device, DevicePropertySetting, QDevicePropertyModel + +if TYPE_CHECKING: + from collections.abc import Iterable + + from PyQt6.QtGui import QAction + + +# TODO: Allow GUI control of parameters +class DeviceTypeFilter(QSortFilterProxyModel): + def __init__(self, allowed: set[DeviceType], parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self.setRecursiveFilteringEnabled(True) + self.allowed = allowed # e.g. {"Camera", "Shutter"} + self.show_read_only = False + self.show_pre_init = False + + def _device_allowed_for_index(self, idx: QModelIndex) -> bool: + """Walk up to the closest Device ancestor and check its type.""" + while idx.isValid(): + data = idx.data(Qt.ItemDataRole.UserRole) + if isinstance(data, Device): + return DeviceType.Any in self.allowed or data.type in self.allowed + idx = idx.parent() # keep climbing + return True # no Device ancestor (root rows etc.) + + def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool: + if (sm := self.sourceModel()) is None: + return super().filterAcceptsRow(source_row, source_parent) + + idx = sm.index(source_row, 0, source_parent) + + # 1. Bail out whole subtree when its Device type is disallowed + if not self._device_allowed_for_index(idx): + return False + + data = idx.data(Qt.ItemDataRole.UserRole) + + # 2. Per-property flags + if isinstance(data, DevicePropertySetting): + if data.is_read_only and not self.show_read_only: + return False + if data.is_pre_init and not self.show_pre_init: + return False + if data.is_advanced: + return False + + # 3. Text / regex filter (superclass logic) + text_match = super().filterAcceptsRow(source_row, source_parent) + + # 4. Special rule for Device rows: hide when it ends up child-less + if isinstance(data, Device): + # If the device name itself matches, keep it only if at least + # one child survives *after all rules above*. + if text_match: + for i in range(sm.rowCount(idx)): + if self.filterAcceptsRow(i, idx): # child survives + return True + # no surviving children -> drop the device row + return False + + # If the device row didn't match the text filter, just return + # False here; Qt will re-accept it automatically if any child + # is accepted (thanks to recursiveFilteringEnabled). + return False + + # 5. For non-Device rows, the decision is simply the text match + return text_match + + def setReadOnlyVisible(self, show: bool) -> None: + """Set whether to show read-only properties.""" + if self.show_read_only != show: + self.show_read_only = show + self.invalidate() + + def setPreInitVisible(self, show: bool) -> None: + """Set whether to show pre-init properties.""" + if self.show_pre_init != show: + self.show_pre_init = show + self.invalidate() + + def setAllowedDeviceTypes(self, allowed: set[DeviceType]) -> None: + """Set the allowed device types.""" + if self.allowed != allowed: + self.allowed = allowed + self.invalidate() + + +class DeviceTypeButtons(QToolBar): + checkedDevicesChanged = Signal(set) + readOnlyToggled = Signal(bool) + preInitToggled = Signal(bool) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + for device_type, icon_key in sorted( + DEVICE_TYPE_ICON.items(), key=lambda x: x[0].name + ): + tooltip = device_type.name.replace("Device", " Devices") + action = cast( + "QAction", + self.addAction( + QIconifyIcon(icon_key, color="gray"), + f"Show {tooltip}", + self._emit_selection, + ), + ) + action.setCheckable(True) + action.setChecked(True) + action.setData(device_type) + + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.addWidget(spacer) + + self.act_show_read_only = cast( + "QAction", + self.addAction( + QIconifyIcon(PROPERTY_FLAG_ICON["read-only"], color="gray"), + "Show Read-Only Properties", + self.readOnlyToggled, + ), + ) + self.act_show_pre_init = cast( + "QAction", + self.addAction( + QIconifyIcon(PROPERTY_FLAG_ICON["pre-init"], color="gray"), + "Show Pre-Init Properties", + self.preInitToggled, + ), + ) + self.act_show_read_only.setCheckable(True) + self.act_show_pre_init.setCheckable(True) + self.act_show_read_only.setChecked(False) + self.act_show_pre_init.setChecked(False) + + def _emit_selection(self) -> None: + """Emit the checkedDevicesChanged signal.""" + self.checkedDevicesChanged.emit(self.checkedDeviceTypes()) + + def setVisibleDeviceTypes(self, device_types: Iterable[DeviceType]) -> None: + """Set the visibility of the device type buttons based on the given types.""" + for action in self.actions(): + if isinstance(data := action.data(), DeviceType): + action.setVisible(data in device_types) + + def setCheckedDeviceTypes(self, device_types: Iterable[DeviceType]) -> None: + """Set the checked state of the device type buttons based on the given types.""" + checked = self.checkedDeviceTypes() + for action in self.actions(): + if isinstance(data := action.data(), DeviceType): + action.setChecked(data in device_types) + if checked != self.checkedDeviceTypes(): + self._emit_selection() + + def checkedDeviceTypes(self) -> set[DeviceType]: + """Return the currently selected device types.""" + return { + data + for action in self.actions() + if (action.isChecked() and action.isVisible()) + if isinstance(data := action.data(), DeviceType) + } + + +class DeviceFilterButtons(QToolBar): + """A toolbar with buttons to filter device types.""" + + expandAllToggled = Signal() + collapseAllToggled = Signal() + + filterStringChanged = Signal(str) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.act_expand = cast( + "QAction", + self.addAction( + QIconifyIcon("mdi:expand-horizontal", color="gray"), + "Expand all", + self.expandAllToggled, + ), + ) + self.act_collapse = cast( + "QAction", + self.addAction( + QIconifyIcon("mdi:collapse-horizontal", color="gray"), + "Collapse all", + self.collapseAllToggled, + ), + ) + self._le = QLineEdit(self) + self._le.setMinimumWidth(160) + self._le.setClearButtonEnabled(True) + self._le.setPlaceholderText("Search properties...") + self._le.textChanged.connect(self.filterStringChanged) + self.addWidget(self._le) + + +class DevicePropertySelector(QWidget): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._model = model = QDevicePropertyModel() + self._proxy = proxy = DeviceTypeFilter(allowed={DeviceType.Any}, parent=self) + proxy.setSourceModel(model) + self._dev_type_btns = DeviceTypeButtons(self) + self._dev_type_btns.setIconSize(QSize(16, 16)) + self._tb2 = DeviceFilterButtons(self) + self._tb2.setIconSize(QSize(16, 16)) + self.setStyleSheet("QToolBar { border: none; };") + self.tree = QTreeView(self) + self.tree.setModel(proxy) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self._dev_type_btns) + layout.addWidget(self._tb2) + layout.addWidget(self.tree) + + self._dev_type_btns.checkedDevicesChanged.connect(proxy.setAllowedDeviceTypes) + self._dev_type_btns.readOnlyToggled.connect(self._proxy.setReadOnlyVisible) + self._dev_type_btns.preInitToggled.connect(self._proxy.setPreInitVisible) + self._tb2.expandAllToggled.connect(self._expand_all) + self._tb2.collapseAllToggled.connect(self.tree.collapseAll) + self._tb2.filterStringChanged.connect(proxy.setFilterFixedString) + + def _expand_all(self) -> None: + """Expand all items in the tree view.""" + self.tree.expandRecursively(QModelIndex()) + + def clear(self) -> None: + """Clear the current selection.""" + # self.table.setValue([]) + + def setChecked(self, settings: Iterable[tuple[str, str, str]]) -> None: + """Set the checked state of the properties based on the given settings.""" + # self.table.setValue(settings) + + def setAvailableDevices(self, devices: Iterable[Device]) -> None: + devices = list(devices) + self._model.set_devices(devices) + self.tree.setColumnHidden(1, True) # Hide the second column (device type) + self.tree.setHeaderHidden(True) + + dev_types = {d.type for d in devices} + self._dev_type_btns.setVisibleDeviceTypes(dev_types) + # # hide some types that are often not immediately useful in this context + # dev_types.difference_update( + # {DeviceType.AutoFocus, DeviceType.Core, DeviceType.Camera} + # ) + # self._device_type_buttons.setCheckedDeviceTypes(dev_types) From c62ea6de2067adc27adbfa076f62897f81949b31 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 4 Jul 2025 15:37:24 -0400 Subject: [PATCH 43/70] Refactor and enhance configuration presets management - Removed the obsolete FlattenProxyModel class and its demo implementation from _flat_chatgpt.py. - Updated QDevicePropertyModel to improve type hinting in the data method. - Added GroupPresetSelector to manage group and preset selections with a unified interface. - Refactored ConfigGroupsEditor to utilize GroupPresetSelector for better organization and functionality. - Improved layout handling and selection synchronization between group and preset views. - Enhanced DeviceTypeFilter to ensure proper return types in filter methods. --- src/pymmcore_widgets/_models/_flat_chatgpt.py | 514 ------------------ .../_models/_q_device_prop_model.py | 2 +- .../config_presets/__init__.py | 2 + .../_views/_config_groups_editor.py | 240 ++++---- .../_views/_device_property_selector.py | 4 +- .../_views/_group_preset_selector.py | 260 +++++++++ 6 files changed, 370 insertions(+), 652 deletions(-) delete mode 100644 src/pymmcore_widgets/_models/_flat_chatgpt.py create mode 100644 src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py diff --git a/src/pymmcore_widgets/_models/_flat_chatgpt.py b/src/pymmcore_widgets/_models/_flat_chatgpt.py deleted file mode 100644 index 607b0d059..000000000 --- a/src/pymmcore_widgets/_models/_flat_chatgpt.py +++ /dev/null @@ -1,514 +0,0 @@ -from __future__ import annotations - -import sys -from typing import NamedTuple - -from qtpy import QtCore, QtWidgets -from qtpy.QtCore import QAbstractProxyModel, QModelIndex, QPersistentModelIndex, Qt -from qtpy.QtGui import QStandardItem, QStandardItemModel -from qtpy.QtWidgets import ( - QApplication, - QComboBox, - QHBoxLayout, - QHeaderView, - QLabel, - QTreeView, - QVBoxLayout, - QWidget, -) - - -class RowInfo(NamedTuple): - leaf: QPersistentModelIndex - ancestors: list[QPersistentModelIndex] - - -class FlattenProxyModel(QAbstractProxyModel): - def __init__(self, level: int = 0, parent: QtCore.QObject | None = None): - super().__init__(parent) - self._level = level - self._rows: list[RowInfo] = [] - self._src2row: dict[QPersistentModelIndex, int] = {} - self._mixed = False # Whether to show mixed hierarchy - self._child_cache: dict[ - QPersistentModelIndex, QPersistentModelIndex - ] = {} # Cache for child indices - - # ------------------------------------------------------------------ - # Debug helpers - # ------------------------------------------------------------------ - def _validate(self, idx: QModelIndex) -> None: - """Verify that every index we emit stores either None or a QPersistentModelIndex.""" - if not idx.isValid(): - return - ip = idx.internalPointer() - if ip is not None and not isinstance(ip, QPersistentModelIndex): - raise RuntimeError(f"Bad internalPointer detected: {ip!r}") - - def _ci(self, row: int, col: int, ptr) -> QModelIndex: - """CreateIndex + validation wrapper.""" - idx = self.createIndex(row, col, ptr) - self._validate(idx) - return idx - - def setLevel(self, level: int): - self._level = level - self._rebuild() - - def setMixed(self, mixed: bool): - self._mixed = mixed - self._rebuild() - - def _rebuild(self): - self.beginResetModel() - self._rows.clear() - self._src2row.clear() - self._child_cache.clear() - self._child_cache.clear() - - if not self.sourceModel(): - self.endResetModel() - return - - if self._level < 0: - # Pass-through mode - self.endResetModel() - return - - # Build flattened rows - self._traverse(QModelIndex(), []) - self.endResetModel() - - def _traverse(self, parent: QModelIndex, ancestors: list[QPersistentModelIndex]): - for r in range(self.sourceModel().rowCount(parent)): - child = self.sourceModel().index(r, 0, parent) - child_ancestors = [*ancestors, QPersistentModelIndex(child)] - - if len(child_ancestors) - 1 == self._level: - # This is a leaf at the desired level - row_info = RowInfo(QPersistentModelIndex(child), child_ancestors) - self._src2row[QPersistentModelIndex(child)] = len(self._rows) - self._rows.append(row_info) - elif len(child_ancestors) - 1 < self._level: - # Keep going deeper - self._traverse(child, child_ancestors) - - def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: - if not self.sourceModel(): - return 0 - - if self._level < 0: - # Pass-through mode - return self.sourceModel().rowCount(self.mapToSource(parent)) - - if not parent.isValid(): - # Top-level: return number of flattened rows - return len(self._rows) - - # Mixed hierarchy: children of flattened rows - if self._mixed and not parent.internalPointer(): - # Children of a flattened row - try: - rowinfo = self._rows[parent.row()] - src_parent = QModelIndex(rowinfo.leaf) - return self.sourceModel().rowCount(src_parent) - except: - return 0 - - # Grandchildren or deeper - # New: if parent.internalPointer() is a QPersistentModelIndex - if self._mixed and isinstance(parent.internalPointer(), QPersistentModelIndex): - src_parent_persistent = parent.internalPointer() - if src_parent_persistent.isValid(): - src_parent = QModelIndex(src_parent_persistent) - return self.sourceModel().rowCount(src_parent) - - return 0 - - def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: - if not self.sourceModel(): - return 0 - if self._level < 0: - # Pass-through mode - return self.sourceModel().columnCount() - # In flattened mode, show columns for each level in the hierarchy - return self._level + 1 - - def hasChildren(self, parent: QModelIndex = QModelIndex()) -> bool: - if not self.sourceModel(): - return False - - if self._level < 0: - # Pass-through mode - src_index = self.mapToSource(parent) - return self.sourceModel().hasChildren(src_index) - - if not parent.isValid(): - # Top-level always has children (the flattened rows) - return len(self._rows) > 0 - - # Mixed hierarchy: check if flattened rows have children - if self._mixed and not parent.internalPointer(): - try: - if parent.row() < len(self._rows): - rowinfo = self._rows[parent.row()] - # Recreate QModelIndex from QPersistentModelIndex using its data - if rowinfo.leaf.isValid(): - # Get the parent of the leaf to recreate the hierarchy - persistent_parent = ( - rowinfo.leaf.parent() - if rowinfo.leaf.parent().isValid() - else QModelIndex() - ) - src_index = self.sourceModel().index( - rowinfo.leaf.row(), rowinfo.leaf.column(), persistent_parent - ) - return self.sourceModel().hasChildren(src_index) - except Exception: - pass - return False - - # Grandchildren or deeper - # New: handle QPersistentModelIndex stored in internalPointer - if self._mixed and isinstance(parent.internalPointer(), QPersistentModelIndex): - src_parent_persistent = parent.internalPointer() - if src_parent_persistent.isValid(): - src_parent = QModelIndex(src_parent_persistent) - return self.sourceModel().hasChildren(src_parent) - return False - - return False - - def index( - self, row: int, col: int, parent: QModelIndex = QModelIndex() - ) -> QModelIndex: - if row < 0 or col < 0: - return QModelIndex() - - if not self.sourceModel(): - return QModelIndex() - - # Pass-through mode - if self._level < 0: - try: - src_parent = self.mapToSource(parent) - if not src_parent.isValid() and parent.isValid(): - return QModelIndex() - return self._ci(row, col, None) - except Exception: - return QModelIndex() - - # Top-level flattened row - if not parent.isValid(): - if row >= len(self._rows) or col >= self.columnCount(): - return QModelIndex() - return self._ci(row, col, None) - - # Mixed hierarchy disabled - no children - if not self._mixed: - return QModelIndex() - - # Mixed hierarchy: children of flattened rows - if parent.internalPointer() is None: - if parent.row() >= len(self._rows): - return QModelIndex() - rowinfo = self._rows[parent.row()] - if not rowinfo.leaf.isValid(): - return QModelIndex() - src_parent = rowinfo.leaf.model().index( - rowinfo.leaf.row(), - rowinfo.leaf.column(), - QModelIndex(), - ) - if col != 0: - return QModelIndex() - if row >= self.sourceModel().rowCount(src_parent): - return QModelIndex() - src_child = self.sourceModel().index(row, 0, src_parent) - if not src_child.isValid(): - return QModelIndex() - # Use the persistent source index directly as internal pointer - persistent = QPersistentModelIndex(src_child) - self._child_cache[persistent] = persistent - return self._ci(row, col, persistent) - - # Grandchildren or deeper - elif parent.internalPointer() is not None: - # Use the persistent source index directly as internal pointer - persistent = QPersistentModelIndex( - self.sourceModel().index(row, 0, QModelIndex(parent.internalPointer())) - ) - src_parent_persistent = parent.internalPointer() - if ( - not isinstance(src_parent_persistent, QPersistentModelIndex) - or not src_parent_persistent.isValid() - ): - return QModelIndex() - src_parent = QModelIndex(src_parent_persistent) - if col != 0: - return QModelIndex() - if row >= self.sourceModel().rowCount(src_parent): - return QModelIndex() - src_child = self.sourceModel().index(row, 0, src_parent) - if not src_child.isValid(): - return QModelIndex() - persistent = QPersistentModelIndex(src_child) - self._child_cache[persistent] = persistent - return self._ci(row, col, persistent) - - return QModelIndex() - - def parent(self, child: QModelIndex) -> QModelIndex: - if not child.isValid() or self._level < 0: - return QModelIndex() - - # If this is a top-level flattened row, it has no parent - if child.internalPointer() is None: - return QModelIndex() - - # If this is a mixed hierarchy child - if self._mixed and isinstance(child.internalPointer(), QPersistentModelIndex): - child_persistent = child.internalPointer() - if not child_persistent.isValid(): - return QModelIndex() - - src_child = QModelIndex(child_persistent) - src_parent = src_child.parent() - - psrc_parent = QPersistentModelIndex(src_parent) - if psrc_parent in self._src2row: - return self._ci(self._src2row[psrc_parent], 0, None) - - if src_parent.isValid(): - parent_persistent = QPersistentModelIndex(src_parent) - self._child_cache[parent_persistent] = parent_persistent - return self._ci(src_parent.row(), 0, parent_persistent) - - return QModelIndex() - - return QModelIndex() - - def mapToSource(self, proxy: QModelIndex) -> QModelIndex: - if not proxy.isValid() or not self.sourceModel(): - return QModelIndex() - - if self._level < 0: - # Pass-through mode - try: - return self.sourceModel().index( - proxy.row(), proxy.column(), QModelIndex() - ) - except Exception: - return QModelIndex() - - # Top-level flattened row showing hierarchical path - if proxy.internalPointer() is None: - if proxy.row() >= len(self._rows): - return QModelIndex() - row_info = self._rows[proxy.row()] - # Return the ancestor at the requested column level - if proxy.column() < len(row_info.ancestors): - ancestor = row_info.ancestors[proxy.column()] - if isinstance(ancestor, QPersistentModelIndex) and ancestor.isValid(): - return QModelIndex(ancestor) - return QModelIndex() - - # Mixed hierarchy child - if self._mixed and isinstance(proxy.internalPointer(), QPersistentModelIndex): - cached_persistent = proxy.internalPointer() - if cached_persistent.isValid(): - return QModelIndex(cached_persistent) - return QModelIndex() - - return QModelIndex() - - def mapFromSource(self, src: QModelIndex) -> QModelIndex: - if not src.isValid() or not self.sourceModel(): - return QModelIndex() - - if self._level < 0: - # Pass-through mode - try: - return self._ci(src.row(), src.column(), None) - except: - return QModelIndex() - - psrc = QPersistentModelIndex(src) - - # Check if this is a flattened row - if (row := self._src2row.get(psrc)) is not None: - return self.index(row, 0) - - # Check if this is in the ancestors of any flattened row - for r, info in enumerate(self._rows): - if psrc in info.ancestors: - col = info.ancestors.index(psrc) - return self.index(r, col) - - if self._mixed: - psrc_child = QPersistentModelIndex(src) - self._child_cache[psrc_child] = psrc_child - return self._ci(src.row(), 0, psrc_child) - - return QModelIndex() - - def data(self, idx: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): - if not idx.isValid() or not self.sourceModel(): - return None - try: - return self.sourceModel().data(self.mapToSource(idx), role) - except: - return None - - def headerData( - self, - section: int, - orient: Qt.Orientation, - role: int = Qt.ItemDataRole.DisplayRole, - ): - if not self.sourceModel(): - return None - - if ( - orient == Qt.Orientation.Horizontal - and role == Qt.ItemDataRole.DisplayRole - and self._level >= 0 - ): - if section == 0: - return "A" - elif section == 1: - return "B" - elif section == 2: - return "C" - elif section == 3: - return "D" - elif section == 4: - return "E" - else: - return f"Level {section}" - - return self.sourceModel().headerData(section, orient, role) - - def setSourceModel(self, model): - if self.sourceModel(): - try: - self.sourceModel().modelReset.disconnect(self._rebuild) - except: - pass - super().setSourceModel(model) - if model: - model.modelReset.connect(self._rebuild) - self._rebuild() - - -# Demo window -class DemoWindow(QWidget): - def __init__(self): - super().__init__() - self.setWindowTitle("Tree Flatten Demo") - self.resize(1200, 600) - - # Create source model - self.src_model = QStandardItemModel() - self._populate_model() - - # Create proxy model - self.flatten = FlattenProxyModel(level=3) - self.flatten.setSourceModel(self.src_model) - - from qtpy.QtTest import QAbstractItemModelTester - - QAbstractItemModelTester( - self.flatten, - QAbstractItemModelTester.FailureReportingMode.Fatal, - self, - ) - - # Create layout - layout = QVBoxLayout(self) - - # Controls - controls = QHBoxLayout() - - # Level selector - controls.addWidget(QLabel("Flatten Level:")) - self.level_combo = QComboBox() - self.level_combo.addItems( - ["Pass-through", "Level 0 - A", "Level 1 - B", "Level 2 - C", "Level 3 - D"] - ) - self.level_combo.setCurrentIndex(4) # Level 3 - D - self.level_combo.currentIndexChanged.connect(self._on_level_changed) - controls.addWidget(self.level_combo) - - # Mixed hierarchy checkbox - self.mixed_check = QtWidgets.QCheckBox("Mixed Hierarchy") - self.mixed_check.setChecked(True) # Try with True to test - self.mixed_check.toggled.connect(self._on_mixed_changed) - controls.addWidget(self.mixed_check) - - controls.addStretch() - layout.addLayout(controls) - - # Views - views_layout = QHBoxLayout() - - # Original view - orig_view = QTreeView() - orig_view.setModel(self.src_model) - orig_view.expandAll() - orig_view.header().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) - views_layout.addWidget(orig_view) - - # Flattened view - flat_view = QTreeView() - flat_view.setModel(self.flatten) - flat_view.expandAll() - flat_view.header().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) - flat_view.header().setStretchLastSection(True) - views_layout.addWidget(flat_view) - - layout.addLayout(views_layout) - - # Set initial state - self._on_level_changed(2) # Level 2 - B - self._on_mixed_changed(True) # Try with mixed hierarchy - - def _populate_model(self): - root = self.src_model.invisibleRootItem() - d = 2 - for i in range(d): - item_a = QStandardItem(f"A{i}") - root.appendRow(item_a) - for j in range(d): - item_b = QStandardItem(f"B{j}") - item_a.appendRow(item_b) - for k in range(d): - item_c = QStandardItem(f"C{k}") - item_b.appendRow(item_c) - # for l in range(d): - # item_d = QStandardItem(f"D{l}") - # item_c.appendRow(item_d) - # for m in range(d): - # item_e = QStandardItem(f"E{m}") - # item_d.appendRow(item_e) - - def _parse_level_from_combo(self, index: int) -> int: - if index == 0: - return -1 # Pass-through - return index - 1 - - def _on_level_changed(self, index: int): - level = self._parse_level_from_combo(index) - self.flatten.setLevel(level) - - def _on_mixed_changed(self, checked: bool): - self.flatten.setMixed(checked) - - -if __name__ == "__main__": - app = QApplication(sys.argv) - window = DemoWindow() - - window.show() - # sys.exit(app.exec()) - app.processEvents() diff --git a/src/pymmcore_widgets/_models/_q_device_prop_model.py b/src/pymmcore_widgets/_models/_q_device_prop_model.py index 2b708a3d4..cc6782520 100644 --- a/src/pymmcore_widgets/_models/_q_device_prop_model.py +++ b/src/pymmcore_widgets/_models/_q_device_prop_model.py @@ -193,7 +193,7 @@ def get_devices(self) -> list[Device]: # return 0 # # data, flags, setData simply delegate to the source -------------------- -# def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: +# def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) ->Any: # if sm := self.sourceModel(): # return sm.data(self.mapToSource(index), role) # return None diff --git a/src/pymmcore_widgets/config_presets/__init__.py b/src/pymmcore_widgets/config_presets/__init__.py index 0b41fbc26..fa0791e57 100644 --- a/src/pymmcore_widgets/config_presets/__init__.py +++ b/src/pymmcore_widgets/config_presets/__init__.py @@ -6,11 +6,13 @@ from ._views._config_groups_tree import ConfigGroupsTree from ._views._config_presets_table import ConfigPresetsTable from ._views._config_views import ConfigGroupsEditor +from ._views._group_preset_selector import GroupPresetSelector __all__ = [ "ConfigGroupsEditor", "ConfigGroupsTree", "ConfigPresetsTable", + "GroupPresetSelector", "GroupPresetTableWidget", "ObjectivesPixelConfigurationWidget", "PixelConfigurationWidget", diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index 40e62e0ad..c223adf61 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -5,14 +5,7 @@ from typing import TYPE_CHECKING, cast from qtpy.QtCore import QModelIndex, Qt, Signal -from qtpy.QtWidgets import ( - QListView, - QSizePolicy, - QSplitter, - QToolBar, - QVBoxLayout, - QWidget, -) +from qtpy.QtWidgets import QSizePolicy, QSplitter, QToolBar, QVBoxLayout, QWidget from superqt import QIconifyIcon from pymmcore_widgets._models import ( @@ -22,11 +15,10 @@ get_config_groups, get_loaded_devices, ) -from pymmcore_widgets.config_presets._views._config_presets_table import ( - ConfigPresetsTable, -) +from ._config_presets_table import ConfigPresetsTable from ._device_property_selector import DevicePropertySelector +from ._group_preset_selector import GroupPresetSelector if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Sequence @@ -100,6 +92,16 @@ def __init__(self, parent: QWidget | None = None) -> None: # widgets -------------------------------------------------------------- self._tb = QToolBar(self) + icon = QIconifyIcon("fluent:layout-column-two-16-regular") + icon.addKey( + "fluent:list-bar-tree-20-regular", + state=QIconifyIcon.State.On, + color="#666", + ) + if act := self._tb.addAction(icon, "Toggle Tree View", self._toggle_tree_view): + act.setCheckable(True) + act.setChecked(False) + self._tb.addAction("Add Group") self._tb.addAction("Add Preset") self._tb.addAction("Remove") @@ -109,18 +111,34 @@ def __init__(self, parent: QWidget | None = None) -> None: spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) self._tb.addWidget(spacer) - icon = QIconifyIcon("mdi:toggle-switch-off-outline") - icon.addKey("mdi:toggle-switch-outline", state=QIconifyIcon.State.On) + icon = QIconifyIcon( + "fluent:layout-row-two-split-top-focus-bottom-16-filled", color="#666" + ) + icon.addKey( + "fluent:layout-column-two-split-left-focus-right-16-filled", + state=QIconifyIcon.State.On, + color="#666", + ) if act := self._tb.addAction(icon, "Wide Layout", self.setLayoutMode): act.setCheckable(True) - self.group_list = QListView(self) - self.group_list.setModel(self._model) - self.group_list.setSelectionMode(QListView.SelectionMode.SingleSelection) + # ------------------------------------------------------------------ + # The GroupPresetSelector can switch between 2-list and tree views: + # ┌───────────────┬───────────────┬───────────────┐ + # │ groups │ presets │ ... │ + # ├───────────────┴───────────────┴───────────────┤ + # │ ... │ + # └───────────────────────────────────────────────┘ + # ┌───────────────────────────────┬───────────────┐ + # │ tree │ │ + # ├───────────────────────────────┴───────────────┤ + # │ ... │ + # └───────────────────────────────────────────────┘ + + self._group_preset_stack = GroupPresetSelector(self) + self._group_preset_stack.setModel(self._model) - self.preset_list = QListView(self) - self.preset_list.setModel(self._model) - self.preset_list.setSelectionMode(QListView.SelectionMode.SingleSelection) + # ------------------------------------------------------------------ self._prop_selector = DevicePropertySelector() @@ -139,35 +157,24 @@ def __init__(self, parent: QWidget | None = None) -> None: # signals ------------------------------------------------------------ - if sm := self.group_list.selectionModel(): - sm.currentChanged.connect(self._on_group_sel) - if sm := self.preset_list.selectionModel(): - sm.currentChanged.connect(self._on_preset_sel) + self._group_preset_stack.groupSelectionChanged.connect(self._on_group_sel) + self._group_preset_stack.presetSelectionChanged.connect(self._on_preset_sel) self._model.dataChanged.connect(self._on_model_data_changed) # self._props.valueChanged.connect(self._on_prop_table_changed) # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ + def _toggle_tree_view(self) -> None: + self._group_preset_stack.toggleView() def setCurrentGroup(self, group: str) -> None: """Set the currently selected group in the editor.""" - idx = self._model.index_for_group(group) - if idx.isValid(): - self.group_list.setCurrentIndex(idx) - else: - self.group_list.clearSelection() + self._group_preset_stack.setCurrentGroup(group) def setCurrentPreset(self, group: str, preset: str) -> None: """Set the currently selected preset in the editor.""" - self.setCurrentGroup(group) - group_index = self._model.index_for_group(group) - idx = self._model.index_for_preset(group_index, preset) - if idx.isValid(): - self.preset_list.setCurrentIndex(idx) - self.preset_list.setFocus() - else: - self.preset_list.clearSelection() + self._group_preset_stack.setCurrentPreset(group, preset) def setData(self, data: Iterable[ConfigGroup]) -> None: """Set the configuration data to be displayed in the editor.""" @@ -176,10 +183,13 @@ def setData(self, data: Iterable[ConfigGroup]) -> None: # self._props.setValue([]) # Auto-select first group if self._model.rowCount(): - self.group_list.setCurrentIndex(self._model.index(0)) + idx = self._model.index(0) + if hasattr(idx, "internalPointer"): + node = idx.internalPointer() + if hasattr(node, "name"): + self._group_preset_stack.setCurrentGroup(node.name) else: - self.preset_list.setRootIndex(QModelIndex()) - self.preset_list.clearSelection() + self._group_preset_stack.clearSelection() self.configChanged.emit() def data(self) -> Sequence[ConfigGroup]: @@ -189,9 +199,8 @@ def data(self) -> Sequence[ConfigGroup]: # selection sync --------------------------------------------------------- def _on_group_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: - self.preset_list.setRootIndex(current) + # The GroupPresetSelector already handles updating the preset list root # self._props._presets_table.setGroup(current) - self.preset_list.clearSelection() self._prop_selector.clear() def _on_preset_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: @@ -213,14 +222,14 @@ def _on_preset_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: def _on_prop_table_changed(self) -> None: """Write back edits from the table into the underlying ConfigPreset.""" - idx = self._preset_view.currentIndex() + idx = self._group_preset_stack.currentPresetIndex() if not idx.isValid(): return node = cast("_Node", idx.internalPointer()) if not node.is_preset: return - new_settings = self._props.value() - self._model.update_preset_settings(idx, new_settings) + # new_settings = self._props.value() + # self._model.update_preset_settings(idx, new_settings) self.configChanged.emit() def _on_model_data_changed( @@ -230,18 +239,18 @@ def _on_model_data_changed( _roles: list[int] | None = None, ) -> None: """Refresh DevicePropertyTable if a setting in the current preset was edited.""" - if not (preset := self._our_preset_changed_by_range(topLeft, bottomRight)): + if not self._our_preset_changed_by_range(topLeft, bottomRight): return - self._props.blockSignals(True) # avoid feedback loop - self._props.setValue(preset.settings) - self._props.blockSignals(False) + # self._props.blockSignals(True) # avoid feedback loop + # self._props.setValue(preset.settings) + # self._props.blockSignals(False) def _our_preset_changed_by_range( self, topLeft: QModelIndex, bottomRight: QModelIndex ) -> ConfigPreset | None: """Return our current preset if it was changed in the given range.""" - cur_preset = self._preset_view.currentIndex() + cur_preset = self._group_preset_stack.currentPresetIndex() if ( not cur_preset.isValid() or not topLeft.isValid() @@ -252,49 +261,43 @@ def _our_preset_changed_by_range( return None # pull updated settings from the model and push to the table - node = cast("_Node", self._preset_view.currentIndex().internalPointer()) + node = cast("_Node", cur_preset.internalPointer()) preset = cast("ConfigPreset", node.payload) return preset def _build_layout(self, mode: LayoutMode) -> QSplitter: """Return a new top-level splitter for the requested layout.""" if mode is LayoutMode.FAVOR_PRESETS: - # ┌───────────────┬───────────────┬───────────────┐ - # │ groups │ presets │ props │ - # ├───────────────┴───────────────┴───────────────┤ - # │ presets-table │ - # └───────────────────────────────────────────────┘ + # ┌───────────────────────────────┬────────────────┐ + # │ _group_preset_stack │ _prop_selector │ <- top_splitter + # ├───────────────────────────────┴────────────────┤ + # │ _preset_table │ + # └────────────────────────────────────────────────┘ top_splitter = QSplitter(Qt.Orientation.Horizontal) - top_splitter.addWidget(self.group_list) - top_splitter.addWidget(self.preset_list) + top_splitter.addWidget(self._group_preset_stack) top_splitter.addWidget(self._prop_selector) - top_splitter.setStretchFactor(2, 1) + top_splitter.setStretchFactor(1, 1) main = QSplitter(Qt.Orientation.Vertical) - main.setChildrenCollapsible(False) main.addWidget(top_splitter) main.addWidget(self._preset_table) return main if mode is LayoutMode.FAVOR_PROPERTIES: - # ┌───────────────┬───────────────┬───────────────┐ - # │ groups │ presets │ │ - # ├───────────────┴───────────────┤ props │ - # │ presets-table │ │ - # └───────────────────────────────┴───────────────┘ - lists_splitter = QSplitter(Qt.Orientation.Horizontal) - lists_splitter.addWidget(self.group_list) - lists_splitter.addWidget(self.preset_list) + # ┌───────────────────────────────┬────────────────┐ + # │ _group_preset_stack │ │ + # ├───────────────────────────────┤ _prop_selector │ + # │ _preset_table │ │ + # └───────────────────────────────┴────────────────┘ left_splitter = QSplitter(Qt.Orientation.Vertical) - left_splitter.addWidget(lists_splitter) + left_splitter.addWidget(self._group_preset_stack) left_splitter.addWidget(self._preset_table) main = QSplitter(Qt.Orientation.Horizontal) - main.setChildrenCollapsible(False) main.addWidget(left_splitter) main.addWidget(self._prop_selector) - main.setStretchFactor(2, 1) + main.setStretchFactor(1, 1) return main raise ValueError(f"Unknown layout mode: {mode}") @@ -307,91 +310,58 @@ def setLayoutMode(self, mode: LayoutMode | None = None) -> None: if mode is None: if not isinstance(sender := self.sender(), QAction): return - mode = ( - LayoutMode.FAVOR_PROPERTIES - if sender.isChecked() - else LayoutMode.FAVOR_PRESETS - ) + checked = sender.isChecked() + mode = LayoutMode.FAVOR_PROPERTIES if checked else LayoutMode.FAVOR_PRESETS else: mode = LayoutMode(mode) - sizes = self._get_list_sizes() # get splitter sizes if available - - with updates_disabled(self): + sizes = None + with _updates_disabled(self): if isinstance( cur_splitter := getattr(self, "_main_splitter", None), QSplitter ): + sizes = self._get_splitter_sizes(cur_splitter) layout.removeWidget(cur_splitter) cur_splitter.setParent(None) cur_splitter.deleteLater() # build and insert the replacement self._main_splitter = new_splitter = self._build_layout(mode) - layout.addWidget(new_splitter) - if sizes is not None: - self._set_list_sizes(*sizes, main_splitter=new_splitter) self._current_mode = mode + layout.addWidget(new_splitter) - def _get_list_sizes(self) -> tuple[list[int], list[int], list[int]] | None: - main_splitter = getattr(self, "_main_splitter", None) - if not isinstance(main_splitter, QSplitter): - return None - - # FAVOR_PRESETS - if main_splitter.orientation() == Qt.Orientation.Vertical and isinstance( - top_splitter := main_splitter.widget(0), QSplitter - ): - list_widths = top_splitter.sizes()[:2] - left_heights = main_splitter.sizes() - sum_width = sum(list_widths) + main_splitter.handleWidth() - full_width = top_splitter.size().width() - return list_widths, left_heights, [sum_width, full_width - sum_width] - - # FAVOR_PROPERTIES - elif ( - main_splitter.orientation() == Qt.Orientation.Horizontal - and isinstance(left_splitter := main_splitter.widget(0), QSplitter) - and isinstance(lists_splitter := left_splitter.widget(0), QSplitter) - ): - list_widths = lists_splitter.sizes() - left_heights = left_splitter.sizes() - full_width = main_splitter.size().width() - return ( - list_widths, - left_heights, - [*list_widths, full_width - sum(list_widths)], - ) - + if sizes is not None: + self._set_splitter_sizes(*sizes, new_splitter) + + def _get_splitter_sizes( + self, splitter: QSplitter + ) -> tuple[list[int], list[int]] | None: + if isinstance(inner_splitter := splitter.widget(0), QSplitter): + # FAVOR_PRESETS + if splitter.orientation() == Qt.Orientation.Vertical: + return inner_splitter.sizes(), splitter.sizes() + # FAVOR_PROPERTIES + else: + return splitter.sizes(), inner_splitter.sizes() return None - def _set_list_sizes( - self, - list_widths: list[int], - left_heights: list[int], - top_splits: list[int], - main_splitter: QSplitter, + def _set_splitter_sizes( + self, top_splits: list[int], left_heights: list[int], main_splitter: QSplitter ) -> None: - """Set the saved sizes of the group and preset lists.""" - if main_splitter.orientation() == Qt.Orientation.Vertical and isinstance( - top_splitter := main_splitter.widget(0), QSplitter - ): - top_splitter.setSizes(top_splits) - main_splitter.setSizes(left_heights) - - # FAVOR_PROPERTIES - elif ( - main_splitter.orientation() == Qt.Orientation.Horizontal - and isinstance(left_splitter := main_splitter.widget(0), QSplitter) - and isinstance(lists_splitter := left_splitter.widget(0), QSplitter) - ): - main_splitter.setSizes(top_splits) - lists_splitter.setSizes(list_widths) - left_splitter.setSizes(left_heights) + """Set the saved sizes of the splitters.""" + if isinstance(inner_splitter := main_splitter.widget(0), QSplitter): + # FAVOR_PRESETS + if main_splitter.orientation() == Qt.Orientation.Vertical: + inner_splitter.setSizes(top_splits) + main_splitter.setSizes(left_heights) + # FAVOR_PROPERTIES + else: + main_splitter.setSizes(top_splits) + inner_splitter.setSizes(left_heights) @contextmanager -def updates_disabled(widget: QWidget) -> Iterator[None]: - """Check if updates are currently disabled for the widget.""" +def _updates_disabled(widget: QWidget) -> Iterator[None]: """Context manager to temporarily disable updates for a widget.""" was_enabled = widget.updatesEnabled() widget.setUpdatesEnabled(False) diff --git a/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py index 806504dd9..503a05f5d 100644 --- a/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py +++ b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py @@ -44,7 +44,7 @@ def _device_allowed_for_index(self, idx: QModelIndex) -> bool: def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool: if (sm := self.sourceModel()) is None: - return super().filterAcceptsRow(source_row, source_parent) + return super().filterAcceptsRow(source_row, source_parent) # type: ignore [no-any-return] idx = sm.index(source_row, 0, source_parent) @@ -83,7 +83,7 @@ def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool: return False # 5. For non-Device rows, the decision is simply the text match - return text_match + return text_match # type: ignore [no-any-return] def setReadOnlyVisible(self, show: bool) -> None: """Set whether to show read-only properties.""" diff --git a/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py b/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py new file mode 100644 index 000000000..5016d78df --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING + +from qtpy.QtCore import QItemSelectionModel, QModelIndex, QSignalBlocker, Qt, Signal +from qtpy.QtWidgets import QListView, QSplitter, QStackedWidget, QWidget + +from pymmcore_widgets._models import QConfigGroupsModel + +from ._config_groups_tree import ConfigGroupsTree + +if TYPE_CHECKING: + from qtpy.QtCore import QAbstractItemModel + + +class GroupPresetSelector(QStackedWidget): + """Widget that switches between list views and tree view for config groups. + + This widget contains: + - A splitter with separate list views for groups and presets (index 0) + - A tree view showing the hierarchical structure (index 1) + + The preset list and tree view share a selection model for consistency, + while the group list uses its own selection model to show visual feedback + when presets are selected (grayed out parent group selection). + + Signals + ------- + groupSelectionChanged : Signal[QModelIndex, QModelIndex] + Emitted when the group selection changes (current, previous) + presetSelectionChanged : Signal[QModelIndex, QModelIndex] + Emitted when the preset selection changes (current, previous) + """ + + groupSelectionChanged = Signal(QModelIndex, QModelIndex) + presetSelectionChanged = Signal(QModelIndex, QModelIndex) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + + self._model: QConfigGroupsModel | None = None + self._selection_model = QItemSelectionModel() + + # STACK_0 : List views for groups and presets ---------------------- + + self.group_list = QListView(self) + self.group_list.setSelectionMode(QListView.SelectionMode.SingleSelection) + + self.preset_list = QListView(self) + self.preset_list.setSelectionMode(QListView.SelectionMode.SingleSelection) + + # Create the splitter for the list views + lists_splitter = QSplitter(Qt.Orientation.Horizontal) + lists_splitter.addWidget(self.group_list) + lists_splitter.addWidget(self.preset_list) + + # STACK_1 : Tree view for config groups ---------------------------- + + self.config_groups_tree = ConfigGroupsTree(self) + + # MAIN STACK ------------------------------------------------------ + + self.addWidget(lists_splitter) # index 0 + self.addWidget(self.config_groups_tree) # index 1 + + self._selection_model.currentChanged.connect(self._on_current_changed) + + def _on_current_changed(self, current: QModelIndex, previous: QModelIndex) -> None: + if not current.isValid(): + return + + is_preset = current.parent().isValid() + + # Emit the same high-level signals your old slots produced. + if is_preset: + self.presetSelectionChanged.emit(current, previous) + else: + self.groupSelectionChanged.emit(current, previous) + + # Keep the three views visually in sync. + self._sync_to_lists(current) + self._sync_to_tree(current) + + def _on_group_selection_changed( + self, current: QModelIndex, previous: QModelIndex + ) -> None: + """Handle group selection changes and emit signal.""" + # Update preset list root to show presets for the selected group + self.preset_list.setRootIndex(current) + self.preset_list.clearSelection() + self.groupSelectionChanged.emit(current, previous) + self._sync_to_tree(current) + + def _on_group_current_changed( + self, current: QModelIndex, previous: QModelIndex + ) -> None: + """Handle group selection changes from the group list.""" + if not current.isValid(): + return + + # When group is selected directly, clear preset selection and update views + self._selection_model.clearCurrentIndex() + self.preset_list.setRootIndex(current) + self.preset_list.clearSelection() + self.groupSelectionChanged.emit(current, previous) + self._sync_to_tree(current) + + # --------------------------------------------------------------------- + # Synchronisation helpers + # --------------------------------------------------------------------- + def _sync_to_tree(self, idx: QModelIndex) -> None: + """Select *idx* in the tree view while blocking feedback loops.""" + if not idx.isValid(): + return + with QSignalBlocker(self.config_groups_tree.selectionModel()): + self.config_groups_tree.setCurrentIndex(idx) + self.config_groups_tree.scrollTo(idx) + + def _sync_to_lists(self, idx: QModelIndex) -> None: + """Reflect *idx* in the list views while blocking feedback loops.""" + if not idx.isValid(): + return + + # A group lives at depth-0, a preset at depth-1. + is_preset = idx.parent().isValid() + group_idx = idx.parent() if is_preset else idx + + # Always set the preset list root to show presets for the current group + self.preset_list.setRootIndex(group_idx) + + # Update group list selection (this will show grayed out when not focused) + group_sel_model = self.group_list.selectionModel() + if group_sel_model is not None: + with QSignalBlocker(group_sel_model): + group_sel_model.setCurrentIndex( + group_idx, QItemSelectionModel.SelectionFlag.ClearAndSelect + ) + self.group_list.scrollTo(group_idx) + + if is_preset: + # Preset is already current in main selection model + self.preset_list.scrollTo(idx) + else: + # For group selection: clear preset selection + self.preset_list.clearSelection() + + def selectionModel(self) -> QItemSelectionModel: + """Return the shared selection model for this widget.""" + return self._selection_model + + def model(self) -> QConfigGroupsModel | None: + """Return the currently attached model.""" + return self._model + + def setModel(self, model: QAbstractItemModel | None) -> None: + """Attach *model* to all views and give them one shared selection model.""" + if not isinstance(model, QConfigGroupsModel): + raise TypeError("Model must be an instance of QConfigGroupsModel") + + self._model = model + self._selection_model.setModel(model) + + # Set models for all views + self.group_list.setModel(model) + self.preset_list.setModel(model) + self.config_groups_tree.setModel(model) + + # Use shared selection model for preset list and tree + self.preset_list.setSelectionModel(self._selection_model) + self.config_groups_tree.setSelectionModel(self._selection_model) + + # Connect to group list's built-in selection model + group_sel_model = self.group_list.selectionModel() + if group_sel_model is not None: + group_sel_model.currentChanged.connect(self._on_group_current_changed) + + def showListViews(self) -> None: + """Switch to list view mode (groups and presets side by side).""" + self.setCurrentIndex(0) + + def showTreeView(self) -> None: + """Switch to tree view mode (hierarchical view).""" + self.setCurrentIndex(1) + + def toggleView(self) -> None: + """Toggle between list view and tree view modes.""" + if self.currentIndex() == 0: + self.showTreeView() + else: + self.showListViews() + + def isTreeViewActive(self) -> bool: + """Return True if tree view is currently active.""" + return bool(self.currentIndex() == 1) + + def setCurrentGroup(self, group: str) -> QModelIndex: + """Set the currently selected group by name.""" + if not (model := self._model): + warnings.warn( + "Model is not set. Cannot set current group.", + UserWarning, + stacklevel=2, + ) + return QModelIndex() + + idx = model.index_for_group(group) + if idx.isValid(): + group_sel_model = self.group_list.selectionModel() + if group_sel_model is not None: + group_sel_model.setCurrentIndex( + idx, QItemSelectionModel.SelectionFlag.ClearAndSelect + ) + else: + group_sel_model = self.group_list.selectionModel() + if group_sel_model is not None: + group_sel_model.clearCurrentIndex() + return idx + + def setCurrentPreset(self, group: str, preset: str) -> QModelIndex: + """Set the currently selected preset by group and preset name.""" + if not (model := self._model): + warnings.warn( + "Model is not set. Cannot set current preset.", + UserWarning, + stacklevel=2, + ) + return QModelIndex() + + group_index = self.setCurrentGroup(group) + idx = model.index_for_preset(group_index, preset) + if idx.isValid(): + self._selection_model.setCurrentIndex( + idx, QItemSelectionModel.SelectionFlag.ClearAndSelect + ) + self.preset_list.setFocus() + else: + self._selection_model.clearCurrentIndex() + return idx + + def currentGroupIndex(self) -> QModelIndex: + """Return the currently selected group index.""" + group_sel_model = self.group_list.selectionModel() + if group_sel_model is not None: + return group_sel_model.currentIndex() + return QModelIndex() + + def currentPresetIndex(self) -> QModelIndex: + """Return the currently selected preset index.""" + return self._selection_model.currentIndex() + + def clearSelection(self) -> None: + """Clear selection in all views.""" + group_sel_model = self.group_list.selectionModel() + if group_sel_model is not None: + group_sel_model.clearCurrentIndex() + self._selection_model.clearCurrentIndex() + self.config_groups_tree.clearSelection() + # Reset preset list root + self.preset_list.setRootIndex(QModelIndex()) From 39ef6a25f6e1bfeaecc625ce5c224abcdd3e7406 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 4 Jul 2025 18:17:11 -0400 Subject: [PATCH 44/70] good selection sync --- .../_views/_config_groups_editor.py | 173 ++++++------- .../_views/_group_preset_selector.py | 236 +++++++----------- 2 files changed, 181 insertions(+), 228 deletions(-) diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index c223adf61..a772563a6 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -2,7 +2,7 @@ from contextlib import contextmanager from enum import Enum, auto -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from qtpy.QtCore import QModelIndex, Qt, Signal from qtpy.QtWidgets import QSizePolicy, QSplitter, QToolBar, QVBoxLayout, QWidget @@ -10,7 +10,6 @@ from pymmcore_widgets._models import ( ConfigGroup, - ConfigPreset, QConfigGroupsModel, get_config_groups, get_loaded_devices, @@ -26,7 +25,6 @@ from pymmcore_plus import CMMCorePlus from PyQt6.QtGui import QAction - from pymmcore_widgets._models._base_tree_model import _Node else: from qtpy.QtGui import QAction @@ -42,7 +40,17 @@ class LayoutMode(Enum): class ConfigGroupsEditor(QWidget): - """Widget composed of two QListViews backed by a single tree model.""" + """Widget composed of two QListViews backed by a single tree model. + + ``` + ┌────────────┬────────────┬───────────────┐ + │ groups/presets | prop_sel │ + ├────────────┴────────────+ - - - - - - - ┤ (layout toggleable) + │ 2D Presets Table | │ + └─────────────────────────┴───────────────┘ + ``` + + """ configChanged = Signal() @@ -82,10 +90,6 @@ def update_from_core( self._preset_table.setModel(self._model) self._preset_table.setGroup("Channel") - # if update_available: - # self._props._update_device_buttons(core) - # self._prop_tables.update_options_from_core(core) - def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._model = QConfigGroupsModel() @@ -135,8 +139,8 @@ def __init__(self, parent: QWidget | None = None) -> None: # │ ... │ # └───────────────────────────────────────────────┘ - self._group_preset_stack = GroupPresetSelector(self) - self._group_preset_stack.setModel(self._model) + self._group_preset_sel = GroupPresetSelector(self) + self._group_preset_sel.setModel(self._model) # ------------------------------------------------------------------ @@ -157,24 +161,37 @@ def __init__(self, parent: QWidget | None = None) -> None: # signals ------------------------------------------------------------ - self._group_preset_stack.groupSelectionChanged.connect(self._on_group_sel) - self._group_preset_stack.presetSelectionChanged.connect(self._on_preset_sel) - self._model.dataChanged.connect(self._on_model_data_changed) + self._group_preset_sel.currentGroupChanged.connect(self._on_group_changed) + self._group_preset_sel.currentPresetChanged.connect(self._on_preset_changed) + # self._group_preset_stack.presetSelectionChanged.connect(self._on_preset_sel) + # self._model.dataChanged.connect(self._on_model_data_changed) # self._props.valueChanged.connect(self._on_prop_table_changed) # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ + def _on_group_changed(self, current: QModelIndex, previous: QModelIndex) -> None: + """Called when the group selection in the GroupPresetSelector changes.""" + self._preset_table.setGroup(current) + + def _on_preset_changed(self, current: QModelIndex, previous: QModelIndex) -> None: + """Called when the preset selection in the GroupPresetSelector changes.""" + if not current.isValid(): + return + view = self._preset_table.view + row = current.row() + view.selectRow(row) if view.isTransposed() else view.selectColumn(row) + def _toggle_tree_view(self) -> None: - self._group_preset_stack.toggleView() + self._group_preset_sel.toggleView() def setCurrentGroup(self, group: str) -> None: """Set the currently selected group in the editor.""" - self._group_preset_stack.setCurrentGroup(group) + self._group_preset_sel.setCurrentGroup(group) def setCurrentPreset(self, group: str, preset: str) -> None: """Set the currently selected preset in the editor.""" - self._group_preset_stack.setCurrentPreset(group, preset) + self._group_preset_sel.setCurrentPreset(group, preset) def setData(self, data: Iterable[ConfigGroup]) -> None: """Set the configuration data to be displayed in the editor.""" @@ -187,84 +204,19 @@ def setData(self, data: Iterable[ConfigGroup]) -> None: if hasattr(idx, "internalPointer"): node = idx.internalPointer() if hasattr(node, "name"): - self._group_preset_stack.setCurrentGroup(node.name) + self._group_preset_sel.setCurrentGroup(node.name) else: - self._group_preset_stack.clearSelection() + self._group_preset_sel.clearSelection() self.configChanged.emit() def data(self) -> Sequence[ConfigGroup]: """Return the current configuration data as a list of ConfigGroup.""" return self._model.get_groups() - # selection sync --------------------------------------------------------- - - def _on_group_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: - # The GroupPresetSelector already handles updating the preset list root - # self._props._presets_table.setGroup(current) - self._prop_selector.clear() - - def _on_preset_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: - """Populate the DevicePropertyTable whenever the selected preset changes.""" - if not current.isValid(): - # clear table when nothing is selected - # self._props.setValue([]) - return - node = cast("_Node", current.internalPointer()) - if not node.is_preset: - # self._props.setValue([]) - return - cast("ConfigPreset", node.payload) - # self._prop_selector.setChecked(preset.settings) - # ------------------------------------------------------------------ - # Property-table sync + # Layout management # ------------------------------------------------------------------ - def _on_prop_table_changed(self) -> None: - """Write back edits from the table into the underlying ConfigPreset.""" - idx = self._group_preset_stack.currentPresetIndex() - if not idx.isValid(): - return - node = cast("_Node", idx.internalPointer()) - if not node.is_preset: - return - # new_settings = self._props.value() - # self._model.update_preset_settings(idx, new_settings) - self.configChanged.emit() - - def _on_model_data_changed( - self, - topLeft: QModelIndex, - bottomRight: QModelIndex, - _roles: list[int] | None = None, - ) -> None: - """Refresh DevicePropertyTable if a setting in the current preset was edited.""" - if not self._our_preset_changed_by_range(topLeft, bottomRight): - return - - # self._props.blockSignals(True) # avoid feedback loop - # self._props.setValue(preset.settings) - # self._props.blockSignals(False) - - def _our_preset_changed_by_range( - self, topLeft: QModelIndex, bottomRight: QModelIndex - ) -> ConfigPreset | None: - """Return our current preset if it was changed in the given range.""" - cur_preset = self._group_preset_stack.currentPresetIndex() - if ( - not cur_preset.isValid() - or not topLeft.isValid() - or topLeft.parent() != cur_preset.parent() - or topLeft.internalPointer().payload.name - != cur_preset.internalPointer().payload.name - ): - return None - - # pull updated settings from the model and push to the table - node = cast("_Node", cur_preset.internalPointer()) - preset = cast("ConfigPreset", node.payload) - return preset - def _build_layout(self, mode: LayoutMode) -> QSplitter: """Return a new top-level splitter for the requested layout.""" if mode is LayoutMode.FAVOR_PRESETS: @@ -274,7 +226,7 @@ def _build_layout(self, mode: LayoutMode) -> QSplitter: # │ _preset_table │ # └────────────────────────────────────────────────┘ top_splitter = QSplitter(Qt.Orientation.Horizontal) - top_splitter.addWidget(self._group_preset_stack) + top_splitter.addWidget(self._group_preset_sel) top_splitter.addWidget(self._prop_selector) top_splitter.setStretchFactor(1, 1) @@ -291,7 +243,7 @@ def _build_layout(self, mode: LayoutMode) -> QSplitter: # └───────────────────────────────┴────────────────┘ left_splitter = QSplitter(Qt.Orientation.Vertical) - left_splitter.addWidget(self._group_preset_stack) + left_splitter.addWidget(self._group_preset_sel) left_splitter.addWidget(self._preset_table) main = QSplitter(Qt.Orientation.Horizontal) @@ -359,6 +311,55 @@ def _set_splitter_sizes( main_splitter.setSizes(top_splits) inner_splitter.setSizes(left_heights) + # ------------------------------------------------------------------ + # Property-table sync + # ------------------------------------------------------------------ + + # def _on_prop_table_changed(self) -> None: + # """Write back edits from the table into the underlying ConfigPreset.""" + # idx = self._group_preset_sel.currentPresetIndex() + # if not idx.isValid(): + # return + # node = cast("_Node", idx.internalPointer()) + # if not node.is_preset: + # return + # # new_settings = self._props.value() + # # self._model.update_preset_settings(idx, new_settings) + # self.configChanged.emit() + + # def _on_model_data_changed( + # self, + # topLeft: QModelIndex, + # bottomRight: QModelIndex, + # _roles: list[int] | None = None, + # ) -> None: + # """Refresh DevicePropertyTable if the current preset was edited.""" + # if not self._our_preset_changed_by_range(topLeft, bottomRight): + # return + + # # self._props.blockSignals(True) # avoid feedback loop + # # self._props.setValue(preset.settings) + # # self._props.blockSignals(False) + + # def _our_preset_changed_by_range( + # self, topLeft: QModelIndex, bottomRight: QModelIndex + # ) -> ConfigPreset | None: + # """Return our current preset if it was changed in the given range.""" + # cur_preset = self._group_preset_sel.currentPresetIndex() + # if ( + # not cur_preset.isValid() + # or not topLeft.isValid() + # or topLeft.parent() != cur_preset.parent() + # or topLeft.internalPointer().payload.name + # != cur_preset.internalPointer().payload.name + # ): + # return None + + # # pull updated settings from the model and push to the table + # node = cast("_Node", cur_preset.internalPointer()) + # preset = cast("ConfigPreset", node.payload) + # return preset + @contextmanager def _updates_disabled(widget: QWidget) -> Iterator[None]: diff --git a/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py b/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py index 5016d78df..ba50f85b6 100644 --- a/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py +++ b/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py @@ -3,7 +3,7 @@ import warnings from typing import TYPE_CHECKING -from qtpy.QtCore import QItemSelectionModel, QModelIndex, QSignalBlocker, Qt, Signal +from qtpy.QtCore import QModelIndex, QSignalBlocker, Qt, Signal from qtpy.QtWidgets import QListView, QSplitter, QStackedWidget, QWidget from pymmcore_widgets._models import QConfigGroupsModel @@ -15,32 +15,20 @@ class GroupPresetSelector(QStackedWidget): - """Widget that switches between list views and tree view for config groups. + """Widget that switches between column (2-list) and tree view for config groups. This widget contains: - A splitter with separate list views for groups and presets (index 0) - A tree view showing the hierarchical structure (index 1) - - The preset list and tree view share a selection model for consistency, - while the group list uses its own selection model to show visual feedback - when presets are selected (grayed out parent group selection). - - Signals - ------- - groupSelectionChanged : Signal[QModelIndex, QModelIndex] - Emitted when the group selection changes (current, previous) - presetSelectionChanged : Signal[QModelIndex, QModelIndex] - Emitted when the preset selection changes (current, previous) """ - groupSelectionChanged = Signal(QModelIndex, QModelIndex) - presetSelectionChanged = Signal(QModelIndex, QModelIndex) + currentPresetChanged = Signal(QModelIndex, QModelIndex) + currentGroupChanged = Signal(QModelIndex, QModelIndex) def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._model: QConfigGroupsModel | None = None - self._selection_model = QItemSelectionModel() # STACK_0 : List views for groups and presets ---------------------- @@ -58,97 +46,18 @@ def __init__(self, parent: QWidget | None = None) -> None: # STACK_1 : Tree view for config groups ---------------------------- self.config_groups_tree = ConfigGroupsTree(self) + self.config_groups_tree.setSelectionMode( + ConfigGroupsTree.SelectionMode.SingleSelection + ) + self.config_groups_tree.setSelectionBehavior( + ConfigGroupsTree.SelectionBehavior.SelectRows + ) # MAIN STACK ------------------------------------------------------ self.addWidget(lists_splitter) # index 0 self.addWidget(self.config_groups_tree) # index 1 - self._selection_model.currentChanged.connect(self._on_current_changed) - - def _on_current_changed(self, current: QModelIndex, previous: QModelIndex) -> None: - if not current.isValid(): - return - - is_preset = current.parent().isValid() - - # Emit the same high-level signals your old slots produced. - if is_preset: - self.presetSelectionChanged.emit(current, previous) - else: - self.groupSelectionChanged.emit(current, previous) - - # Keep the three views visually in sync. - self._sync_to_lists(current) - self._sync_to_tree(current) - - def _on_group_selection_changed( - self, current: QModelIndex, previous: QModelIndex - ) -> None: - """Handle group selection changes and emit signal.""" - # Update preset list root to show presets for the selected group - self.preset_list.setRootIndex(current) - self.preset_list.clearSelection() - self.groupSelectionChanged.emit(current, previous) - self._sync_to_tree(current) - - def _on_group_current_changed( - self, current: QModelIndex, previous: QModelIndex - ) -> None: - """Handle group selection changes from the group list.""" - if not current.isValid(): - return - - # When group is selected directly, clear preset selection and update views - self._selection_model.clearCurrentIndex() - self.preset_list.setRootIndex(current) - self.preset_list.clearSelection() - self.groupSelectionChanged.emit(current, previous) - self._sync_to_tree(current) - - # --------------------------------------------------------------------- - # Synchronisation helpers - # --------------------------------------------------------------------- - def _sync_to_tree(self, idx: QModelIndex) -> None: - """Select *idx* in the tree view while blocking feedback loops.""" - if not idx.isValid(): - return - with QSignalBlocker(self.config_groups_tree.selectionModel()): - self.config_groups_tree.setCurrentIndex(idx) - self.config_groups_tree.scrollTo(idx) - - def _sync_to_lists(self, idx: QModelIndex) -> None: - """Reflect *idx* in the list views while blocking feedback loops.""" - if not idx.isValid(): - return - - # A group lives at depth-0, a preset at depth-1. - is_preset = idx.parent().isValid() - group_idx = idx.parent() if is_preset else idx - - # Always set the preset list root to show presets for the current group - self.preset_list.setRootIndex(group_idx) - - # Update group list selection (this will show grayed out when not focused) - group_sel_model = self.group_list.selectionModel() - if group_sel_model is not None: - with QSignalBlocker(group_sel_model): - group_sel_model.setCurrentIndex( - group_idx, QItemSelectionModel.SelectionFlag.ClearAndSelect - ) - self.group_list.scrollTo(group_idx) - - if is_preset: - # Preset is already current in main selection model - self.preset_list.scrollTo(idx) - else: - # For group selection: clear preset selection - self.preset_list.clearSelection() - - def selectionModel(self) -> QItemSelectionModel: - """Return the shared selection model for this widget.""" - return self._selection_model - def model(self) -> QConfigGroupsModel | None: """Return the currently attached model.""" return self._model @@ -159,24 +68,86 @@ def setModel(self, model: QAbstractItemModel | None) -> None: raise TypeError("Model must be an instance of QConfigGroupsModel") self._model = model - self._selection_model.setModel(model) # Set models for all views self.group_list.setModel(model) self.preset_list.setModel(model) self.config_groups_tree.setModel(model) - # Use shared selection model for preset list and tree - self.preset_list.setSelectionModel(self._selection_model) - self.config_groups_tree.setSelectionModel(self._selection_model) + self._connect_selection_models() - # Connect to group list's built-in selection model - group_sel_model = self.group_list.selectionModel() - if group_sel_model is not None: - group_sel_model.currentChanged.connect(self._on_group_current_changed) + def _connect_selection_models(self) -> None: + """Connect all selection model signals to slots.""" + # TODO: Disconnect + if group_sel := self.group_list.selectionModel(): + group_sel.currentChanged.connect(self._on_group_selection_changed) + + if preset_sel := self.preset_list.selectionModel(): + preset_sel.currentChanged.connect(self._on_preset_selection_changed) + + if tree_sel := self.config_groups_tree.selectionModel(): + tree_sel.currentChanged.connect(self._on_tree_selection_changed) + + def _on_group_selection_changed( + self, current: QModelIndex, previous: QModelIndex + ) -> None: + """Handle change in the group_list selection.""" + prev_preset = self.preset_list.currentIndex() + self.preset_list.setRootIndex(current) + self.preset_list.setCurrentIndex(QModelIndex()) + with QSignalBlocker(self.config_groups_tree.selectionModel()): + self.config_groups_tree.collapseAll() + self.config_groups_tree.setCurrentIndex(current) + self.config_groups_tree.expand(current) - def showListViews(self) -> None: - """Switch to list view mode (groups and presets side by side).""" + self.currentGroupChanged.emit(current, previous) + if prev_preset.isValid(): + self.currentPresetChanged.emit(QModelIndex(), prev_preset) + + def _on_preset_selection_changed( + self, current: QModelIndex, previous: QModelIndex + ) -> None: + """Handle change in the preset_list selection.""" + with QSignalBlocker(self.config_groups_tree.selectionModel()): + self.config_groups_tree.setCurrentIndex(current) + self.currentPresetChanged.emit(current, previous) + + def _group_preset_index(self, idx: QModelIndex) -> tuple[QModelIndex, QModelIndex]: + """Extract group and preset indices from a given index.""" + parent = idx.parent() + # idx is a SETTING + if (group_idx := parent.parent()).isValid(): + return group_idx, parent + # idx is a PRESET + if parent.isValid(): + return parent, idx + # idx is a GROUP + return idx, QModelIndex() + + def _on_tree_selection_changed( + self, current: QModelIndex, previous: QModelIndex + ) -> None: + """Handle change in the config_groups_tree selection.""" + group_idx, preset_idx = self._group_preset_index(current) + prev_group, prev_preset = self._group_preset_index(previous) + + if group_idx.row() != prev_group.row(): + self.currentGroupChanged.emit(group_idx, prev_group) + if prev_preset.isValid(): + self.currentPresetChanged.emit(preset_idx, prev_preset) + elif preset_idx.row() != prev_preset.row(): + self.currentPresetChanged.emit(preset_idx, prev_preset) + + with ( + QSignalBlocker(self.group_list.selectionModel()), + QSignalBlocker(self.preset_list.selectionModel()), + ): + self.group_list.setCurrentIndex(group_idx) + self.preset_list.setRootIndex(group_idx) + self.preset_list.setCurrentIndex(preset_idx) + + def showColumnView(self) -> None: + """Switch to column view mode (groups and presets side by side).""" self.setCurrentIndex(0) def showTreeView(self) -> None: @@ -184,11 +155,11 @@ def showTreeView(self) -> None: self.setCurrentIndex(1) def toggleView(self) -> None: - """Toggle between list view and tree view modes.""" + """Toggle between column view and tree view modes.""" if self.currentIndex() == 0: self.showTreeView() else: - self.showListViews() + self.showColumnView() def isTreeViewActive(self) -> bool: """Return True if tree view is currently active.""" @@ -205,18 +176,17 @@ def setCurrentGroup(self, group: str) -> QModelIndex: return QModelIndex() idx = model.index_for_group(group) - if idx.isValid(): - group_sel_model = self.group_list.selectionModel() - if group_sel_model is not None: - group_sel_model.setCurrentIndex( - idx, QItemSelectionModel.SelectionFlag.ClearAndSelect - ) - else: - group_sel_model = self.group_list.selectionModel() - if group_sel_model is not None: - group_sel_model.clearCurrentIndex() + self.group_list.setCurrentIndex(idx) return idx + def currentGroup(self) -> QModelIndex: + """Return the currently selected group index.""" + return self.group_list.currentIndex() + + def currentPreset(self) -> QModelIndex: + """Return the currently selected preset index.""" + return self.preset_list.currentIndex() + def setCurrentPreset(self, group: str, preset: str) -> QModelIndex: """Set the currently selected preset by group and preset name.""" if not (model := self._model): @@ -229,32 +199,14 @@ def setCurrentPreset(self, group: str, preset: str) -> QModelIndex: group_index = self.setCurrentGroup(group) idx = model.index_for_preset(group_index, preset) - if idx.isValid(): - self._selection_model.setCurrentIndex( - idx, QItemSelectionModel.SelectionFlag.ClearAndSelect - ) - self.preset_list.setFocus() - else: - self._selection_model.clearCurrentIndex() + self.preset_list.setCurrentIndex(idx) return idx - def currentGroupIndex(self) -> QModelIndex: - """Return the currently selected group index.""" - group_sel_model = self.group_list.selectionModel() - if group_sel_model is not None: - return group_sel_model.currentIndex() - return QModelIndex() - - def currentPresetIndex(self) -> QModelIndex: - """Return the currently selected preset index.""" - return self._selection_model.currentIndex() - def clearSelection(self) -> None: """Clear selection in all views.""" group_sel_model = self.group_list.selectionModel() if group_sel_model is not None: group_sel_model.clearCurrentIndex() - self._selection_model.clearCurrentIndex() self.config_groups_tree.clearSelection() # Reset preset list root self.preset_list.setRootIndex(QModelIndex()) From e05e0247dfda858b10773bda54a767e1e440e324 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 4 Jul 2025 18:48:38 -0400 Subject: [PATCH 45/70] activate buttons --- .../_models/_q_config_model.py | 4 +- .../_views/_config_groups_editor.py | 121 ++++++++++++------ .../_views/_group_preset_selector.py | 30 +++-- 3 files changed, 101 insertions(+), 54 deletions(-) diff --git a/src/pymmcore_widgets/_models/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py index 9b74d873d..33ea8b8fe 100644 --- a/src/pymmcore_widgets/_models/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -202,7 +202,7 @@ def index_for_preset( def add_group(self, base_name: str = "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, QModelIndex(), _payloads=[group]): @@ -232,7 +232,7 @@ def add_preset( if not isinstance(group_node.payload, ConfigGroup): raise ValueError("Reference index is not a ConfigGroup.") - name = self._unique_child_name(group_node, base_name) + name = self._unique_child_name(group_node, base_name, suffix="") preset = ConfigPreset(name=name) row = len(group_node.children) if self.insertRows(row, 1, group_idx, _payloads=[preset]): diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index a772563a6..c8b2b2d3c 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -4,8 +4,14 @@ from enum import Enum, auto from typing import TYPE_CHECKING -from qtpy.QtCore import QModelIndex, Qt, Signal -from qtpy.QtWidgets import QSizePolicy, QSplitter, QToolBar, QVBoxLayout, QWidget +from qtpy.QtCore import QModelIndex, QSize, Qt, Signal +from qtpy.QtWidgets import ( + QSizePolicy, + QSplitter, + QToolBar, + QVBoxLayout, + QWidget, +) from superqt import QIconifyIcon from pymmcore_widgets._models import ( @@ -23,10 +29,10 @@ from collections.abc import Iterable, Iterator, Sequence from pymmcore_plus import CMMCorePlus - from PyQt6.QtGui import QAction + from PyQt6.QtGui import QAction, QActionGroup else: - from qtpy.QtGui import QAction + from qtpy.QtGui import QAction, QActionGroup class LayoutMode(Enum): @@ -94,38 +100,6 @@ def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._model = QConfigGroupsModel() - # widgets -------------------------------------------------------------- - self._tb = QToolBar(self) - icon = QIconifyIcon("fluent:layout-column-two-16-regular") - icon.addKey( - "fluent:list-bar-tree-20-regular", - state=QIconifyIcon.State.On, - color="#666", - ) - if act := self._tb.addAction(icon, "Toggle Tree View", self._toggle_tree_view): - act.setCheckable(True) - act.setChecked(False) - - self._tb.addAction("Add Group") - self._tb.addAction("Add Preset") - self._tb.addAction("Remove") - self._tb.addAction("Duplicate") - - spacer = QWidget(self._tb) - spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - self._tb.addWidget(spacer) - - icon = QIconifyIcon( - "fluent:layout-row-two-split-top-focus-bottom-16-filled", color="#666" - ) - icon.addKey( - "fluent:layout-column-two-split-left-focus-right-16-filled", - state=QIconifyIcon.State.On, - color="#666", - ) - if act := self._tb.addAction(icon, "Wide Layout", self.setLayoutMode): - act.setCheckable(True) - # ------------------------------------------------------------------ # The GroupPresetSelector can switch between 2-list and tree views: # ┌───────────────┬───────────────┬───────────────┐ @@ -150,6 +124,68 @@ def __init__(self, parent: QWidget | None = None) -> None: self._preset_table.setModel(self._model) self._preset_table.setGroup("Channel") + # tool bar -------------------------------------------------------------- + + self._tb = QToolBar(self) + self._tb.setIconSize(QSize(22, 22)) + self._tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + + # Create exclusive action group for view modes + view_action_group = QActionGroup(self) + + # Column View action + column_icon = QIconifyIcon("fluent:layout-column-two-24-regular") + if column_act := self._tb.addAction( + column_icon, "Column View", self._group_preset_sel.showColumnView + ): + column_act.setCheckable(True) + column_act.setChecked(True) + view_action_group.addAction(column_act) + + # Tree View action + tree_icon = QIconifyIcon("fluent:list-bar-tree-20-regular", color="#666") + if tree_act := self._tb.addAction( + tree_icon, "Tree View", self._group_preset_sel.showTreeView + ): + tree_act.setCheckable(True) + view_action_group.addAction(tree_act) + + self._tb.addAction( + QIconifyIcon("fluent:folder-add-24-regular"), + "Add Group", + self._model.add_group, + ) + self._tb.addAction( + QIconifyIcon("fluent:document-add-24-regular"), + "Add Preset", + self._add_preset_to_current_group, + ) + self._tb.addAction( + QIconifyIcon("fluent:delete-24-regular"), + "Remove", + self._group_preset_sel.removeSelected, + ) + self._tb.addAction( + QIconifyIcon("fluent:save-copy-24-regular"), + "Duplicate", + self._group_preset_sel.duplicateSelected, + ) + + spacer = QWidget(self._tb) + spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self._tb.addWidget(spacer) + + icon = QIconifyIcon( + "fluent:layout-row-two-split-top-focus-bottom-16-filled", color="#666" + ) + icon.addKey( + "fluent:layout-column-two-split-left-focus-right-16-filled", + state=QIconifyIcon.State.On, + color="#666", + ) + if act := self._tb.addAction(icon, "Toggle Layout", self.setLayoutMode): + act.setCheckable(True) + # layout ------------------------------------------------------------ self._current_mode: LayoutMode = LayoutMode.FAVOR_PRESETS @@ -182,9 +218,6 @@ def _on_preset_changed(self, current: QModelIndex, previous: QModelIndex) -> Non row = current.row() view.selectRow(row) if view.isTransposed() else view.selectColumn(row) - def _toggle_tree_view(self) -> None: - self._group_preset_sel.toggleView() - def setCurrentGroup(self, group: str) -> None: """Set the currently selected group in the editor.""" self._group_preset_sel.setCurrentGroup(group) @@ -213,6 +246,12 @@ def data(self) -> Sequence[ConfigGroup]: """Return the current configuration data as a list of ConfigGroup.""" return self._model.get_groups() + def _add_preset_to_current_group(self) -> None: + """Add a new preset to the currently selected group.""" + current_group = self._group_preset_sel.currentGroup() + if current_group.isValid(): + self._model.add_preset(current_group) + # ------------------------------------------------------------------ # Layout management # ------------------------------------------------------------------ @@ -228,7 +267,7 @@ def _build_layout(self, mode: LayoutMode) -> QSplitter: top_splitter = QSplitter(Qt.Orientation.Horizontal) top_splitter.addWidget(self._group_preset_sel) top_splitter.addWidget(self._prop_selector) - top_splitter.setStretchFactor(1, 1) + # top_splitter.setStretchFactor(1, 1) main = QSplitter(Qt.Orientation.Vertical) main.addWidget(top_splitter) @@ -249,7 +288,7 @@ def _build_layout(self, mode: LayoutMode) -> QSplitter: main = QSplitter(Qt.Orientation.Horizontal) main.addWidget(left_splitter) main.addWidget(self._prop_selector) - main.setStretchFactor(1, 1) + # main.setStretchFactor(1, 1) return main raise ValueError(f"Unknown layout mode: {mode}") diff --git a/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py b/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py index ba50f85b6..84e9e2769 100644 --- a/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py +++ b/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py @@ -1,6 +1,5 @@ from __future__ import annotations -import warnings from typing import TYPE_CHECKING from qtpy.QtCore import QModelIndex, QSignalBlocker, Qt, Signal @@ -146,6 +145,25 @@ def _on_tree_selection_changed( self.preset_list.setRootIndex(group_idx) self.preset_list.setCurrentIndex(preset_idx) + def _selected_index(self) -> QModelIndex: + """Return the currently selected index from the group or preset list.""" + if self.group_list.hasFocus(): + return self.group_list.currentIndex() + elif self.preset_list.hasFocus(): + return self.preset_list.currentIndex() + return QModelIndex() + + def removeSelected(self) -> None: + if self._model: + self._model.remove(self._selected_index()) + + def duplicateSelected(self) -> None: + if self._model: + if self.group_list.hasFocus(): + self._model.duplicate_group(self.group_list.currentIndex()) + elif self.preset_list.hasFocus(): + self._model.duplicate_preset(self.preset_list.currentIndex()) + def showColumnView(self) -> None: """Switch to column view mode (groups and presets side by side).""" self.setCurrentIndex(0) @@ -168,11 +186,6 @@ def isTreeViewActive(self) -> bool: def setCurrentGroup(self, group: str) -> QModelIndex: """Set the currently selected group by name.""" if not (model := self._model): - warnings.warn( - "Model is not set. Cannot set current group.", - UserWarning, - stacklevel=2, - ) return QModelIndex() idx = model.index_for_group(group) @@ -190,11 +203,6 @@ def currentPreset(self) -> QModelIndex: def setCurrentPreset(self, group: str, preset: str) -> QModelIndex: """Set the currently selected preset by group and preset name.""" if not (model := self._model): - warnings.warn( - "Model is not set. Cannot set current preset.", - UserWarning, - stacklevel=2, - ) return QModelIndex() group_index = self.setCurrentGroup(group) From 8a80fc9940c2dd5ca58e5a254b82c0eb8d08f5f6 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 4 Jul 2025 18:58:50 -0400 Subject: [PATCH 46/70] better icons --- src/pymmcore_widgets/_icons.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pymmcore_widgets/_icons.py b/src/pymmcore_widgets/_icons.py index b764af6c7..95f2d9ac2 100644 --- a/src/pymmcore_widgets/_icons.py +++ b/src/pymmcore_widgets/_icons.py @@ -24,8 +24,8 @@ } PROPERTY_FLAG_ICON: dict[str, str] = { - "read-only": "mdi:lock-outline", - "pre-init": "mdi:alpha-p-box-outline", + "read-only": "fluent:edit-off-20-regular", + "pre-init": "mynaui:letter-p-diamond", } From 5d86439f3422756bbae03360cfefc650bdecf991 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 4 Jul 2025 19:01:36 -0400 Subject: [PATCH 47/70] fix delegate --- .../_views/_property_setting_delegate.py | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) 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) From 3b197851c5f690640aca67540a04f5194528b92c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 4 Jul 2025 20:57:48 -0400 Subject: [PATCH 48/70] Enhance ConfigGroupsEditor layout and add persistent editor functionality --- .../_models/_config_group_pivot_model.py | 4 +-- .../_views/_config_groups_editor.py | 31 +++++++++++++++---- .../_views/_config_presets_table.py | 10 ++++++ test_column_stretching.py | 0 4 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 test_column_stretching.py diff --git a/src/pymmcore_widgets/_models/_config_group_pivot_model.py b/src/pymmcore_widgets/_models/_config_group_pivot_model.py index 319ab8b82..e1e26ac10 100644 --- a/src/pymmcore_widgets/_models/_config_group_pivot_model.py +++ b/src/pymmcore_widgets/_models/_config_group_pivot_model.py @@ -137,11 +137,11 @@ def headerData( orient: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole, ) -> Any: - if role == Qt.ItemDataRole.DisplayRole: + 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: + elif role == Qt.ItemDataRole.DecorationRole and section < len(self._rows): if orient == Qt.Orientation.Vertical: try: dev, _prop = self._rows[section] diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index c8b2b2d3c..97b255b5f 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -6,6 +6,7 @@ from qtpy.QtCore import QModelIndex, QSize, Qt, Signal from qtpy.QtWidgets import ( + QGroupBox, QSizePolicy, QSplitter, QToolBar, @@ -209,6 +210,8 @@ def __init__(self, parent: QWidget | None = None) -> None: def _on_group_changed(self, current: QModelIndex, previous: QModelIndex) -> None: """Called when the group selection in the GroupPresetSelector changes.""" self._preset_table.setGroup(current) + self._preset_table.view.stretchHeaders() + self._preset_table.view.openPersistentEditors() def _on_preset_changed(self, current: QModelIndex, previous: QModelIndex) -> None: """Called when the preset selection in the GroupPresetSelector changes.""" @@ -258,6 +261,22 @@ def _add_preset_to_current_group(self) -> None: def _build_layout(self, mode: LayoutMode) -> QSplitter: """Return a new top-level splitter for the requested layout.""" + margin = 2 + groups_presets = QGroupBox("Navigate Groups && Presets", self) + lay = QVBoxLayout(groups_presets) + lay.setContentsMargins(margin, margin, margin, margin) + lay.addWidget(self._group_preset_sel) + + prop_sel = QGroupBox("Select Properties", self) + lay = QVBoxLayout(prop_sel) + lay.setContentsMargins(margin, margin, margin, margin) + lay.addWidget(self._prop_selector) + + table_group = QGroupBox("Presets Table", self) + lay = QVBoxLayout(table_group) + lay.setContentsMargins(margin, margin, margin, margin) + lay.addWidget(self._preset_table) + if mode is LayoutMode.FAVOR_PRESETS: # ┌───────────────────────────────┬────────────────┐ # │ _group_preset_stack │ _prop_selector │ <- top_splitter @@ -265,13 +284,13 @@ def _build_layout(self, mode: LayoutMode) -> QSplitter: # │ _preset_table │ # └────────────────────────────────────────────────┘ top_splitter = QSplitter(Qt.Orientation.Horizontal) - top_splitter.addWidget(self._group_preset_sel) - top_splitter.addWidget(self._prop_selector) + top_splitter.addWidget(groups_presets) + top_splitter.addWidget(prop_sel) # top_splitter.setStretchFactor(1, 1) main = QSplitter(Qt.Orientation.Vertical) main.addWidget(top_splitter) - main.addWidget(self._preset_table) + main.addWidget(table_group) return main if mode is LayoutMode.FAVOR_PROPERTIES: @@ -282,12 +301,12 @@ def _build_layout(self, mode: LayoutMode) -> QSplitter: # └───────────────────────────────┴────────────────┘ left_splitter = QSplitter(Qt.Orientation.Vertical) - left_splitter.addWidget(self._group_preset_sel) - left_splitter.addWidget(self._preset_table) + left_splitter.addWidget(groups_presets) + left_splitter.addWidget(table_group) main = QSplitter(Qt.Orientation.Horizontal) main.addWidget(left_splitter) - main.addWidget(self._prop_selector) + main.addWidget(prop_sel) # main.setStretchFactor(1, 1) return main 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 12653cd52..a9f63b050 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py @@ -69,6 +69,16 @@ def stretchHeaders(self) -> None: hh.setSectionResizeMode(col, hh.ResizeMode.Stretch) self._have_stretched_headers = True + 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): diff --git a/test_column_stretching.py b/test_column_stretching.py new file mode 100644 index 000000000..e69de29bb From 211601d543450eab8cb15496fe707ec04f8a8e3a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 5 Jul 2025 14:05:48 -0400 Subject: [PATCH 49/70] good flat --- flat.py | 458 +++++++++++++++++++++++++++++++++++++++++ flatten_proxy_demo.png | Bin 0 -> 59457 bytes 2 files changed, 458 insertions(+) create mode 100644 flat.py create mode 100644 flatten_proxy_demo.png diff --git a/flat.py b/flat.py new file mode 100644 index 000000000..2be168378 --- /dev/null +++ b/flat.py @@ -0,0 +1,458 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Any + +from PyQt6 import QtCore, QtGui, QtWidgets + + +class FlattenModel(QtCore.QIdentityProxyModel): + def __init__( + self, row_depth: int = 0, parent: QtCore.QObject | None = None + ) -> None: + super().__init__(parent) + # the row depth determines how many rows we show in the flattened view + # for example, if row_depth=0, we revert to the original model, + self._row_depth = row_depth + self._rows: list[QtCore.QModelIndex] = [] + # Store which rows are expandable and their children + self._expandable_rows: dict[int, list[list[tuple[int, int]]]] = {} + + def headerData( + self, + section: int, + orientation: QtCore.Qt.Orientation, + role: int = QtCore.Qt.ItemDataRole.DisplayRole, + ) -> Any: + if ( + orientation == QtCore.Qt.Orientation.Horizontal + and role == QtCore.Qt.ItemDataRole.DisplayRole + ): + return f"Level {section}" # Level 0 = root, Level N = leaf + return super().headerData(section, orientation, role) + + def rowCount(self, parent: QtCore.QModelIndex | None = None) -> int: + """Return the number of rows under the given parent.""" + if parent is None: + parent = QtCore.QModelIndex() + if self._row_depth <= 0: + return super().rowCount(parent) + + if not parent.isValid(): + # Top-level: return number of primary flattened rows + return len(self._row_paths) + else: + # For a parent row, return number of children if expandable + parent_row = parent.row() + if parent_row in self._expandable_rows: + return len(self._expandable_rows[parent_row]) + return 0 + + def columnCount(self, parent: QtCore.QModelIndex | None = None) -> int: + if parent is None: + parent = QtCore.QModelIndex() + # In the flattened view with hierarchical expansion, + # we need enough columns to show the deepest child data + if self._row_depth <= 0: + return super().columnCount(parent) + + # Calculate the maximum depth needed including children + max_child_depth = self._max_depth + for children_paths in self._expandable_rows.values(): + for path in children_paths: + max_child_depth = max(max_child_depth, len(path) - 1) + + return max_child_depth + 1 + + def set_row_depth(self, row_depth: int) -> None: + self._row_depth = row_depth + self._rebuild() + + def setSourceModel(self, source_model: QtCore.QAbstractItemModel | None) -> None: + super().setSourceModel(source_model) + if source_model: + source_model.dataChanged.connect(self._rebuild) + self._rebuild() + + def _rebuild(self) -> None: + self.beginResetModel() + self._max_depth = 0 + # one entry per proxy row + self._row_paths: list[list[tuple[int, int]]] = [] + # mapping of depth -> (num_rows, num_columns) + self._num_leafs_at_depth = defaultdict[int, int](int) + self._expandable_rows = {} + if src := self.sourceModel(): + self._collect_model_shape(src) + self.endResetModel() + + def _collect_model_shape( + self, + model: QtCore.QAbstractItemModel, + parent: QtCore.QModelIndex | None = None, + depth: int = 0, + stack: list[tuple[int, int]] | None = None, + ) -> None: + if parent is None: + parent = QtCore.QModelIndex() + if stack is None: + stack = [] + + rows = model.rowCount(parent) + self._num_leafs_at_depth[depth] += rows + self._max_depth = max(self._max_depth, depth) + + for r in range(rows): + child = model.index(r, 0, parent) # tree is in column 0 + pair_path = [*stack, (r, 0)] + + if depth == self._row_depth: + # Add the row at the target depth + row_index = len(self._row_paths) + self._row_paths.append(pair_path) + + # If this row has children, store them for potential expansion + if model.hasChildren(child): + children = [] + child_rows = model.rowCount(child) + for child_r in range(child_rows): + child_path = [*pair_path, (child_r, 0)] + children.append(child_path) + self._expandable_rows[row_index] = children + else: + self._collect_model_shape(model, child, depth + 1, pair_path) + + def index( + self, + row: int, + column: int, + parent: QtCore.QModelIndex | None = None, + ) -> QtCore.QModelIndex: + """Returns the index of the item specified by (row, column, parent).""" + if parent is None: + parent = QtCore.QModelIndex() + if self._row_depth <= 0: + return super().index(row, column, parent) + + if not parent.isValid(): + # Top-level rows (the primary flattened rows) + if row < 0 or row >= len(self._row_paths): + return QtCore.QModelIndex() + if column < 0 or column > self._max_depth: + return QtCore.QModelIndex() + return self.createIndex(row, column, 0) # 0 indicates top-level + else: + # Child rows (expanded children of a parent row) + parent_row = parent.row() + if parent_row in self._expandable_rows: + children = self._expandable_rows[parent_row] + if row < 0 or row >= len(children): + return QtCore.QModelIndex() + # For child rows, check against the child path length, not _max_depth + child_path = children[row] + max_child_col = len(child_path) - 1 + if column < 0 or column > max_child_col: + return QtCore.QModelIndex() + # Add bounds checking for parent_row to prevent overflow + if parent_row < 0 or parent_row >= len(self._row_paths): + return QtCore.QModelIndex() + # Use parent_row + 1 as internal pointer to identify this is a child + return self.createIndex(row, column, parent_row + 1) + return QtCore.QModelIndex() + + def parent(self, index: QtCore.QModelIndex) -> QtCore.QModelIndex: + """Returns the parent of the given child index.""" + if self._row_depth <= 0: + return super().parent(index) + + if not index.isValid(): + return QtCore.QModelIndex() + + internal_ptr = index.internalId() + if internal_ptr == 0: + # This is a top-level row, so no parent + return QtCore.QModelIndex() + else: + # This is a child row, parent is at internal_ptr - 1 + parent_row = internal_ptr - 1 + # Add bounds checking to prevent overflow + if parent_row < 0 or parent_row >= len(self._row_paths): + # Invalid parent row, return invalid index + return QtCore.QModelIndex() + return self.createIndex(parent_row, 0, 0) # Parent is always top-level + + def mapToSource(self, proxy_index: QtCore.QModelIndex) -> QtCore.QModelIndex: + """Map from the flattened view back to the source model.""" + if self._row_depth <= 0 or not (src_model := self.sourceModel()): + return super().mapToSource(proxy_index) + + if not proxy_index.isValid(): + return QtCore.QModelIndex() + + row, col = proxy_index.row(), proxy_index.column() + internal_ptr = proxy_index.internalId() + + if internal_ptr == 0: + # Top-level row + if row >= len(self._row_paths): + return QtCore.QModelIndex() + path = self._row_paths[row] + else: + # Child row + parent_row = internal_ptr - 1 + if parent_row not in self._expandable_rows: + return QtCore.QModelIndex() + children = self._expandable_rows[parent_row] + if row >= len(children): + return QtCore.QModelIndex() + path = children[row] + + if col >= len(path): # beyond recorded depth + return QtCore.QModelIndex() + + src = QtCore.QModelIndex() + for r, c in path[: col + 1]: + src = src_model.index(r, c, src) + + return src + + def data( + self, index: QtCore.QModelIndex, role: int = QtCore.Qt.ItemDataRole.DisplayRole + ) -> Any: + """Return data for the given index and role.""" + if self._row_depth <= 0: + return super().data(index, role) + + if not index.isValid() or not (src_model := self.sourceModel()): + return None + + row, col = index.row(), index.column() + internal_ptr = index.internalId() + + if internal_ptr == 0: + # Top-level row + if row >= len(self._row_paths): + return None + path = self._row_paths[row] + else: + # Child row + parent_row = internal_ptr - 1 + if parent_row not in self._expandable_rows: + return None + children = self._expandable_rows[parent_row] + if row >= len(children): + return None + path = children[row] + + if col >= len(path): + return None + + # Navigate to the source index for this column + src = QtCore.QModelIndex() + for _, (r, c) in enumerate(path[: col + 1]): + src = src_model.index(r, c, src) + + return src_model.data(src, role) if src.isValid() else None + + def hasChildren(self, parent: QtCore.QModelIndex | None = None) -> bool: + """Return whether the given index has children.""" + if parent is None: + parent = QtCore.QModelIndex() + + if self._row_depth <= 0: + return super().hasChildren(parent) + + if not parent.isValid(): + return self.rowCount() > 0 + + # Check if this is a top-level row that's expandable + if parent.internalId() == 0: + parent_row = parent.row() + return parent_row in self._expandable_rows + + # Child rows don't have children in this implementation + return False + + +def build_tree_model() -> QtGui.QStandardItemModel: + """Create a simple 5-level tree: A-i / B-j / C-k / D-l / E-m.""" + model = QtGui.QStandardItemModel() + model.setHorizontalHeaderLabels(["Name"]) + + item_a0 = QtGui.QStandardItem("A0") + model.appendRow(item_a0) + item_a1 = QtGui.QStandardItem("A1") + model.appendRow(item_a1) + + item_b00 = QtGui.QStandardItem("B00") + item_a0.appendRow(item_b00) + item_b01 = QtGui.QStandardItem("B01") + item_a0.appendRow(item_b01) + + item_b10 = QtGui.QStandardItem("B10") + item_a1.appendRow(item_b10) + item_b11 = QtGui.QStandardItem("B11") + item_a1.appendRow(item_b11) + + item_c000 = QtGui.QStandardItem("C000") + item_b00.appendRow(item_c000) + item_c001 = QtGui.QStandardItem("C001") + item_b00.appendRow(item_c001) + + item_c111 = QtGui.QStandardItem("C111") + item_b11.appendRow(item_c111) + + return model + + +def print_model( + model: QtCore.QAbstractItemModel, + parent: QtCore.QModelIndex | None = None, + depth: int = 0, +) -> None: + """Print the model structure to the console.""" + if parent is None: + parent = QtCore.QModelIndex() + + rows = model.rowCount(parent) + for r in range(rows): + child = model.index(r, 0, parent) # tree is in column 0 + # print an ascii tree + print(" " * depth + f"- {model.data(child)}") + print_model(model, child, depth + 1) + + +class MainWindow(QtWidgets.QWidget): + """Main demo window.""" + + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("Flatten proxy demo") + + # source tree + src_model = build_tree_model() + print_model(src_model) + + tree1 = QtWidgets.QTreeView() + tree1.setModel(src_model) + tree1.expandAll() + + # proxy + table view + self.proxy = FlattenModel(row_depth=0) + self.proxy.setSourceModel(src_model) + + self.tree2 = tree2 = QtWidgets.QTreeView() + tree2.setModel(self.proxy) + tree1.expandAll() + + # depth selector + depth_selector = QtWidgets.QComboBox() + depth_selector.addItems( + [ + "Rows = level A (depth 0)", + "Rows = level B (depth 1)", + "Rows = level C (depth 2)", + ] + ) + depth_selector.setCurrentIndex(1) + + depth_selector.currentIndexChanged.connect(self.proxy.set_row_depth) + + # layout + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(QtWidgets.QLabel("Source tree")) + layout.addWidget(tree1, 2) + layout.addWidget(QtWidgets.QLabel("Flattened table (sortable)")) + layout.addWidget(tree2, 3) + layout.addWidget(QtWidgets.QLabel("Choose row depth")) + layout.addWidget(depth_selector) + + +def main() -> None: + """Run the demo application.""" + import sys + + app = QtWidgets.QApplication(sys.argv) + win = MainWindow() + win.resize(800, 600) + win.show() + + # PROGRAMMATICALLY INTERACT HERE + print("Testing hierarchical expansion at row_depth=1:") + win.proxy.set_row_depth(1) + print(f"Row count (top-level): {win.proxy.rowCount()}") + + # Print top-level rows + for r in range(win.proxy.rowCount()): + row_data = [] + for c in range(win.proxy.columnCount()): + idx = win.proxy.index(r, c) + data = win.proxy.data(idx) + row_data.append(str(data) if data else "None") + has_children = win.proxy.hasChildren(win.proxy.index(r, 0)) + print(f" Row {r}: {row_data} (has children: {has_children})") + + # If this row has children, print them too + if has_children: + parent_idx = win.proxy.index(r, 0) + child_count = win.proxy.rowCount(parent_idx) + print(f" Children ({child_count}):") + for child_r in range(child_count): + child_data = [] + for c in range(win.proxy.columnCount()): + child_idx = win.proxy.index(child_r, c, parent_idx) + data = win.proxy.data(child_idx) + child_data.append(str(data) if data else "None") + print(f" Child {child_r}: {child_data}") + + # Test bounds checking: try to get parent of child + child_parent = win.proxy.parent(child_idx) + if child_parent.isValid(): + print(f" Child parent row: {child_parent.row()}") + + print("\nStress testing bounds checking:") + # Try to create invalid indexes and see if they handle gracefully + invalid_tests = [ + (999, 0, QtCore.QModelIndex()), # Invalid top-level row + (0, 999, QtCore.QModelIndex()), # Invalid column + (-1, 0, QtCore.QModelIndex()), # Negative row + (0, -1, QtCore.QModelIndex()), # Negative column + ] + + for row, col, parent in invalid_tests: + index = win.proxy.index(row, col, parent) + print(f" index({row}, {col}): valid={index.isValid()}") + if index.isValid(): + # Try to get parent, which might trigger the overflow + try: + parent_index = win.proxy.parent(index) + print(f" parent: valid={parent_index.isValid()}") + except Exception as e: + print(f" parent error: {e}") + + # Try to trigger the overflow with corrupted internal pointers + print("\nTesting with potentially corrupted indexes:") + try: + # Create an index with a very large internal pointer manually + # This simulates what might happen in edge cases + for large_id in [999999, 2**31, 2**63 - 1]: + try: + fake_index = win.proxy.createIndex(0, 0, large_id) + print(f" Created index with internalId={large_id}") + parent_index = win.proxy.parent(fake_index) + print(f" Parent: valid={parent_index.isValid()}") + except Exception as e: + print(f" Error with internalId={large_id}: {e}") + except Exception as e: + print(f" Exception in corruption test: {e}") + + # Expand all in the tree view to show hierarchical structure + win.tree2.expandAll() + + win.grab().save("flatten_proxy_demo.png", "PNG") + sys.exit(app.exec()) + # sys.exit(app.processEvents()) + + +if __name__ == "__main__": + main() diff --git a/flatten_proxy_demo.png b/flatten_proxy_demo.png new file mode 100644 index 0000000000000000000000000000000000000000..2acf50a4d56be1b57e16ef2152a468d3bbf5036d GIT binary patch literal 59457 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 Y Date: Sat, 5 Jul 2025 15:14:15 -0400 Subject: [PATCH 50/70] good flat model --- flat.py | 334 +++++++++++++++++++---------------------- flatten_proxy_demo.png | Bin 59457 -> 53707 bytes 2 files changed, 156 insertions(+), 178 deletions(-) diff --git a/flat.py b/flat.py index 2be168378..afc8a0b2a 100644 --- a/flat.py +++ b/flat.py @@ -7,6 +7,15 @@ class FlattenModel(QtCore.QIdentityProxyModel): + """A proxy model that flattens a tree model into a table view with expandable rows. + + This model allows you to specify a row depth, which determines how many rows are + shown in the flattened view. For example, if row_depth=0, it reverts to the original + model, while row_depth=1 shows the first level of children, and row_depth=2 shows + the second level of children. The model supports expandable rows, allowing users to + expand and collapse child rows based on the specified depth. + """ + def __init__( self, row_depth: int = 0, parent: QtCore.QObject | None = None ) -> None: @@ -18,12 +27,20 @@ def __init__( # Store which rows are expandable and their children self._expandable_rows: dict[int, list[list[tuple[int, int]]]] = {} + def set_row_depth(self, row_depth: int) -> None: + """Set the row depth for the flattened view.""" + self._row_depth = row_depth + self._rebuild() + + # ------------- QAbstractItemModel interface methods ------------- + def headerData( self, section: int, orientation: QtCore.Qt.Orientation, role: int = QtCore.Qt.ItemDataRole.DisplayRole, ) -> Any: + """Return the header data for the given section and orientation.""" if ( orientation == QtCore.Qt.Orientation.Horizontal and role == QtCore.Qt.ItemDataRole.DisplayRole @@ -49,79 +66,23 @@ def rowCount(self, parent: QtCore.QModelIndex | None = None) -> int: return 0 def columnCount(self, parent: QtCore.QModelIndex | None = None) -> int: + """Return the number of columns for the given parent.""" if parent is None: parent = QtCore.QModelIndex() - # In the flattened view with hierarchical expansion, - # we need enough columns to show the deepest child data if self._row_depth <= 0: return super().columnCount(parent) - # Calculate the maximum depth needed including children - max_child_depth = self._max_depth - for children_paths in self._expandable_rows.values(): - for path in children_paths: - max_child_depth = max(max_child_depth, len(path) - 1) - - return max_child_depth + 1 - - def set_row_depth(self, row_depth: int) -> None: - self._row_depth = row_depth - self._rebuild() + # For flattened view, show columns up to row_depth + 1 + # This makes row_depth=1 show 2 columns, row_depth=2 show 3 columns, etc. + return self._row_depth + 1 def setSourceModel(self, source_model: QtCore.QAbstractItemModel | None) -> None: + """Set the source model and rebuild the flattened view.""" super().setSourceModel(source_model) if source_model: source_model.dataChanged.connect(self._rebuild) self._rebuild() - def _rebuild(self) -> None: - self.beginResetModel() - self._max_depth = 0 - # one entry per proxy row - self._row_paths: list[list[tuple[int, int]]] = [] - # mapping of depth -> (num_rows, num_columns) - self._num_leafs_at_depth = defaultdict[int, int](int) - self._expandable_rows = {} - if src := self.sourceModel(): - self._collect_model_shape(src) - self.endResetModel() - - def _collect_model_shape( - self, - model: QtCore.QAbstractItemModel, - parent: QtCore.QModelIndex | None = None, - depth: int = 0, - stack: list[tuple[int, int]] | None = None, - ) -> None: - if parent is None: - parent = QtCore.QModelIndex() - if stack is None: - stack = [] - - rows = model.rowCount(parent) - self._num_leafs_at_depth[depth] += rows - self._max_depth = max(self._max_depth, depth) - - for r in range(rows): - child = model.index(r, 0, parent) # tree is in column 0 - pair_path = [*stack, (r, 0)] - - if depth == self._row_depth: - # Add the row at the target depth - row_index = len(self._row_paths) - self._row_paths.append(pair_path) - - # If this row has children, store them for potential expansion - if model.hasChildren(child): - children = [] - child_rows = model.rowCount(child) - for child_r in range(child_rows): - child_path = [*pair_path, (child_r, 0)] - children.append(child_path) - self._expandable_rows[row_index] = children - else: - self._collect_model_shape(model, child, depth + 1, pair_path) - def index( self, row: int, @@ -138,7 +99,7 @@ def index( # Top-level rows (the primary flattened rows) if row < 0 or row >= len(self._row_paths): return QtCore.QModelIndex() - if column < 0 or column > self._max_depth: + if column < 0 or column >= self.columnCount(): return QtCore.QModelIndex() return self.createIndex(row, column, 0) # 0 indicates top-level else: @@ -160,7 +121,7 @@ def index( return self.createIndex(row, column, parent_row + 1) return QtCore.QModelIndex() - def parent(self, index: QtCore.QModelIndex) -> QtCore.QModelIndex: + def parent(self, index: QtCore.QModelIndex) -> QtCore.QModelIndex: # type: ignore [override] """Returns the parent of the given child index.""" if self._row_depth <= 0: return super().parent(index) @@ -179,7 +140,8 @@ def parent(self, index: QtCore.QModelIndex) -> QtCore.QModelIndex: if parent_row < 0 or parent_row >= len(self._row_paths): # Invalid parent row, return invalid index return QtCore.QModelIndex() - return self.createIndex(parent_row, 0, 0) # Parent is always top-level + # Return parent at column 0 for tree structure + return self.createIndex(parent_row, 0, 0) def mapToSource(self, proxy_index: QtCore.QModelIndex) -> QtCore.QModelIndex: """Map from the flattened view back to the source model.""" @@ -193,12 +155,20 @@ def mapToSource(self, proxy_index: QtCore.QModelIndex) -> QtCore.QModelIndex: internal_ptr = proxy_index.internalId() if internal_ptr == 0: - # Top-level row + # Top-level row: navigate to the column depth requested if row >= len(self._row_paths): return QtCore.QModelIndex() path = self._row_paths[row] + + if col >= len(path): # beyond recorded depth + return QtCore.QModelIndex() + + src = QtCore.QModelIndex() + for r, c in path[: col + 1]: + src = src_model.index(r, c, src) + return src else: - # Child row + # Child row: these are the deeper children (C-level nodes) parent_row = internal_ptr - 1 if parent_row not in self._expandable_rows: return QtCore.QModelIndex() @@ -207,52 +177,66 @@ def mapToSource(self, proxy_index: QtCore.QModelIndex) -> QtCore.QModelIndex: return QtCore.QModelIndex() path = children[row] - if col >= len(path): # beyond recorded depth - return QtCore.QModelIndex() - - src = QtCore.QModelIndex() - for r, c in path[: col + 1]: - src = src_model.index(r, c, src) - - return src - - def data( - self, index: QtCore.QModelIndex, role: int = QtCore.Qt.ItemDataRole.DisplayRole - ) -> Any: - """Return data for the given index and role.""" - if self._row_depth <= 0: - return super().data(index, role) - - if not index.isValid() or not (src_model := self.sourceModel()): - return None - - row, col = index.row(), index.column() - internal_ptr = index.internalId() - - if internal_ptr == 0: - # Top-level row - if row >= len(self._row_paths): - return None - path = self._row_paths[row] - else: - # Child row - parent_row = internal_ptr - 1 - if parent_row not in self._expandable_rows: - return None - children = self._expandable_rows[parent_row] - if row >= len(children): - return None - path = children[row] + # For children, we have different behavior per column: + if col == 0: + # Column 0: Don't show anything for child rows (they should be indented) + return QtCore.QModelIndex() + elif col == self._row_depth: + # Target depth column: show the full child data (navigate the complete path) + src = QtCore.QModelIndex() + for r, c in path: + src = src_model.index(r, c, src) + return src + else: + # Other columns: no data + return QtCore.QModelIndex() - if col >= len(path): - return None + def mapFromSource(self, source_index: QtCore.QModelIndex) -> QtCore.QModelIndex: + """Map from source model back to proxy model.""" + if self._row_depth <= 0 or not (src_model := self.sourceModel()): + return super().mapFromSource(source_index) - # Navigate to the source index for this column - src = QtCore.QModelIndex() - for _, (r, c) in enumerate(path[: col + 1]): - src = src_model.index(r, c, src) + if not source_index.isValid(): + return QtCore.QModelIndex() - return src_model.data(src, role) if src.isValid() else None + # Build the path from root to this source index + path = [] + current = source_index + while current.isValid(): + path.append((current.row(), current.column())) + current = current.parent() + path.reverse() # Now path is from root to source_index + + # Check if this path matches any of our top-level rows + for proxy_row, row_path in enumerate(self._row_paths): + # Check if the source path starts with our row path + if len(path) >= len(row_path): + matches = True + for i, (proxy_r, proxy_c) in enumerate(row_path): + if i >= len(path) or path[i] != (proxy_r, proxy_c): + matches = False + break + + if matches: + # This source index corresponds to our proxy row + if len(path) == len(row_path): + # Exact match - this is a top-level item + column = len(path) - 1 # Column based on depth + if column < self.columnCount(): + return self.createIndex(proxy_row, column, 0) + elif len(path) == len(row_path) + 1: + # This is a child of our top-level item + if proxy_row in self._expandable_rows: + children = self._expandable_rows[proxy_row] + child_row_in_source = path[-1][0] # Last element's row + if child_row_in_source < len(children): + column = len(path) - 1 # Column based on depth + if column < self.columnCount(): + return self.createIndex( + child_row_in_source, column, proxy_row + 1 + ) + + return QtCore.QModelIndex() def hasChildren(self, parent: QtCore.QModelIndex | None = None) -> bool: """Return whether the given index has children.""" @@ -273,6 +257,66 @@ def hasChildren(self, parent: QtCore.QModelIndex | None = None) -> bool: # Child rows don't have children in this implementation return False + def _rebuild(self) -> None: + self.beginResetModel() + self._max_depth = 0 + # one entry per proxy row + self._row_paths: list[list[tuple[int, int]]] = [] + # mapping of depth -> (num_rows, num_columns) + self._num_leaves_at_depth = defaultdict[int, int](int) + self._expandable_rows = {} + if src := self.sourceModel(): + self._collect_model_shape(src) + self.endResetModel() + + def _collect_model_shape( + self, + model: QtCore.QAbstractItemModel, + parent: QtCore.QModelIndex | None = None, + depth: int = 0, + stack: list[tuple[int, int]] | None = None, + ) -> None: + if parent is None: + parent = QtCore.QModelIndex() + if stack is None: + stack = [] + + rows = model.rowCount(parent) + self._num_leaves_at_depth[depth] += rows + self._max_depth = max(self._max_depth, depth) + + for r in range(rows): + child = model.index(r, 0, parent) # tree is in column 0 + pair_path = [*stack, (r, 0)] + + # Add node if we're at target depth OR + # if it's a terminal node before target depth + should_add_node = ( + depth == self._row_depth # At target depth + or ( + depth < self._row_depth and not model.hasChildren(child) + ) # Terminal before target + ) + + if should_add_node: + # Add the row to the flattened view + row_index = len(self._row_paths) + self._row_paths.append(pair_path) + + # If this row has children and we're at target depth, + # store them for expansion + if depth == self._row_depth and model.hasChildren(child): + children = [] + child_rows = model.rowCount(child) + for child_r in range(child_rows): + child_path = [*pair_path, (child_r, 0)] + children.append(child_path) + self._expandable_rows[row_index] = children + + # Continue if we haven't reached target depth and node has children + if depth < self._row_depth and model.hasChildren(child): + self._collect_model_shape(model, child, depth + 1, pair_path) + def build_tree_model() -> QtGui.QStandardItemModel: """Create a simple 5-level tree: A-i / B-j / C-k / D-l / E-m.""" @@ -346,7 +390,7 @@ def __init__(self) -> None: tree1.expandAll() # depth selector - depth_selector = QtWidgets.QComboBox() + self.combo = depth_selector = QtWidgets.QComboBox() depth_selector.addItems( [ "Rows = level A (depth 0)", @@ -378,80 +422,14 @@ def main() -> None: win.show() # PROGRAMMATICALLY INTERACT HERE - print("Testing hierarchical expansion at row_depth=1:") - win.proxy.set_row_depth(1) - print(f"Row count (top-level): {win.proxy.rowCount()}") - - # Print top-level rows - for r in range(win.proxy.rowCount()): - row_data = [] - for c in range(win.proxy.columnCount()): - idx = win.proxy.index(r, c) - data = win.proxy.data(idx) - row_data.append(str(data) if data else "None") - has_children = win.proxy.hasChildren(win.proxy.index(r, 0)) - print(f" Row {r}: {row_data} (has children: {has_children})") - - # If this row has children, print them too - if has_children: - parent_idx = win.proxy.index(r, 0) - child_count = win.proxy.rowCount(parent_idx) - print(f" Children ({child_count}):") - for child_r in range(child_count): - child_data = [] - for c in range(win.proxy.columnCount()): - child_idx = win.proxy.index(child_r, c, parent_idx) - data = win.proxy.data(child_idx) - child_data.append(str(data) if data else "None") - print(f" Child {child_r}: {child_data}") - - # Test bounds checking: try to get parent of child - child_parent = win.proxy.parent(child_idx) - if child_parent.isValid(): - print(f" Child parent row: {child_parent.row()}") - - print("\nStress testing bounds checking:") - # Try to create invalid indexes and see if they handle gracefully - invalid_tests = [ - (999, 0, QtCore.QModelIndex()), # Invalid top-level row - (0, 999, QtCore.QModelIndex()), # Invalid column - (-1, 0, QtCore.QModelIndex()), # Negative row - (0, -1, QtCore.QModelIndex()), # Negative column - ] - - for row, col, parent in invalid_tests: - index = win.proxy.index(row, col, parent) - print(f" index({row}, {col}): valid={index.isValid()}") - if index.isValid(): - # Try to get parent, which might trigger the overflow - try: - parent_index = win.proxy.parent(index) - print(f" parent: valid={parent_index.isValid()}") - except Exception as e: - print(f" parent error: {e}") - - # Try to trigger the overflow with corrupted internal pointers - print("\nTesting with potentially corrupted indexes:") - try: - # Create an index with a very large internal pointer manually - # This simulates what might happen in edge cases - for large_id in [999999, 2**31, 2**63 - 1]: - try: - fake_index = win.proxy.createIndex(0, 0, large_id) - print(f" Created index with internalId={large_id}") - parent_index = win.proxy.parent(fake_index) - print(f" Parent: valid={parent_index.isValid()}") - except Exception as e: - print(f" Error with internalId={large_id}: {e}") - except Exception as e: - print(f" Exception in corruption test: {e}") # Expand all in the tree view to show hierarchical structure + win.combo.setCurrentIndex(1) # Set to depth 1 to show the improved layout win.tree2.expandAll() win.grab().save("flatten_proxy_demo.png", "PNG") - sys.exit(app.exec()) # sys.exit(app.processEvents()) + sys.exit(app.exec()) if __name__ == "__main__": diff --git a/flatten_proxy_demo.png b/flatten_proxy_demo.png index 2acf50a4d56be1b57e16ef2152a468d3bbf5036d..32cd3d7ce635821730b6249d8b84ddc275e3cfac 100644 GIT binary patch delta 34878 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%0A;1f6iQu(_wl(*>* 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 adUXCnYxRi7yB4;f!fEDKlj0N${{BCO=?UKe delta 40290 zcmcG$1yok+-aX3RZWRR-R79l&6@yY*Boq)(B$aLjDG3pfUThOVK^g@?MFb=zq`@Yo zB%~#kZV8Dy7o2mx^Tqv-@7_Bup4Nt9Thxv z^7PdW2ev7wzH6?}wC7Q^b>uI6BqSmjuifuh8Tn)2I@PtRY0+n)I+vxk9NK*2>7go0 zA9lVE^GmO7ez=KJM@jExbQ510f35#JW7gnz3V;=B8->y zsL|V;t36WE(wrJehu95?rI`ne!eiu)m3 z;>SERJj}&zY;63g^U~@Cyo&ErByzdTjd#sY{W$%xv$L}%=Sp+B$M0Xcd3ihSpNgK9 z5p^0iPp^3Q?wM&t=x0G~f%&innzi3oX`@WbtZ3T0m|61q^ERVDR(~y-?QLDk)t=7@G=&BPekeRVe0+SIhEeFs zw>0DTw>Eu!smQ-_0ei7&(-<*Kjk(gWB5KbsM)( z6ErkWBs^SwcXGSVj#~FOV~5!H?c+5piX1bL&cAzCjxL{S_1h{JwLTVdS=7hZ;;|Z> zR8$_$Z+vBP1TnR5-@Y*edZXN&oC_&>xw~99t$jX;GOc{q)~#F3%*^PHdoJdjmb>R| zkm~Q_Q=Tu($EOzVxt{zSL6PZ0k$C@%f`Y=?v)ck(X9gb;!EBL_k6pS`mh$xJ)3mgs zbjI^etCxJL_?MRH85^6?c-5Hn^mLgQR!u4WtUrf_bhB;r>6!~%rVx7fmFLP1M#qnM zd3jwN;adIP$q6I6>kmX-r+>b&ZmoY~kgBYt@;^}^!}bVe4Mokbo~ z*4M9J$N#&Hv}AFMwmxK&-oh%uv}@NcdiuVhAuB7Zh)0hSnubol&`8!9YE0(5!?JGm z0>Ko-pIl#Fzj;%Y{Ti``#Y9#z;|79)&Ucmci{Dd<)6Ka2AV2?ix>i>0)7-jwk5T-uMxGe?Q6SDacyF!RL~z6d4u-a zT_S{H+y4lE%x7B8VCc@qZd*7L`s}s){3I^;<*}gKXzq{Ukr66Ur>XugZnLBG0a*GJ z{rsJ|8&+@czvk6QYh=;HnOg8%h(N~pvr0yVhaY0sOwpT|on4t}GK>(iwu~3^T%3D)>L!m~ zj!J2BWx`sL)a-41TX#rOvY45`wkf;2yUWYVM@x9f^uD{f5ntoGQ1w`_qO;TR!1<+x znTpvf{q<`LX}cgs3O$WVPpCCu3ut z#pa(kDnw@_F6HX#dMtG5w3Cw)%^m^TfZkq{o?krrdDym>a{es0nXLZ#=?$+pXc)i$ z@L~J*?fUxq;^N{|hx6^a-e*}g9nnboh8W&9QvKoX*6L_UUiRhX)N<>_2cIR_2HCr~k2%@jc>;`^TO{ zg#WF+OIcZ&ULU1fVX5iG_m(Ux%P{YpygZF~m1j9T9w@jr?Rg0!_4V}xqp*!$p2L~c zrlwRwqW1aej*gB`j%;gp@?hWFBli;%6TNo?86Y*JR=xg2gxw=1>Y=KIi4L_~yc z0mE9p)bRb8BzNf02R_e~wEcCQbi0LZB3o*PW`u3pyzkxHv~_D|8nS?fk>gzWQR|k> zi^c4#zd?9yz!E=BOtf-!UFfR_>+`2hJR5T4z!QV{`S~wV;;k=^iHf>@fBW~dXV1RI zDcj->$2tmqm-5!`Q0D{t=;eg-aej||4)~jns-M~;XqoA``5zuz9gBb=NHGI>rT3v(yd?rMCF&Pcnm_tg7s_E>7` z);9^(g$B`)kxUE>*;jjHvlc@O3zvL(i{^IanXiSl${?CVqHSl<{2MeO{C}a-NQ-~2 z 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 TE6=SG|7OaB>EmN$PQU*j%-IhN From 1838b2b68cb64e9276aeedd64183d809c8fe86a5 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 5 Jul 2025 15:17:36 -0400 Subject: [PATCH 51/70] wip --- flat.py | 48 ++++++++++++++++++++--------------------- flatten_proxy_demo.png | Bin 53707 -> 51386 bytes 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/flat.py b/flat.py index afc8a0b2a..64a94bad5 100644 --- a/flat.py +++ b/flat.py @@ -58,12 +58,31 @@ def rowCount(self, parent: QtCore.QModelIndex | None = None) -> int: if not parent.isValid(): # Top-level: return number of primary flattened rows return len(self._row_paths) - else: - # For a parent row, return number of children if expandable + + # For a parent row, return number of children if expandable + parent_row = parent.row() + if parent_row in self._expandable_rows: + return len(self._expandable_rows[parent_row]) + return 0 + + def hasChildren(self, parent: QtCore.QModelIndex | None = None) -> bool: + """Return whether the given index has children.""" + if parent is None: + parent = QtCore.QModelIndex() + + if self._row_depth <= 0: + return super().hasChildren(parent) + + if not parent.isValid(): + return self.rowCount() > 0 + + # Check if this is a top-level row that's expandable + if parent.internalId() == 0: parent_row = parent.row() - if parent_row in self._expandable_rows: - return len(self._expandable_rows[parent_row]) - return 0 + return parent_row in self._expandable_rows + + # Child rows don't have children in this implementation + return False def columnCount(self, parent: QtCore.QModelIndex | None = None) -> int: """Return the number of columns for the given parent.""" @@ -238,25 +257,6 @@ def mapFromSource(self, source_index: QtCore.QModelIndex) -> QtCore.QModelIndex: return QtCore.QModelIndex() - def hasChildren(self, parent: QtCore.QModelIndex | None = None) -> bool: - """Return whether the given index has children.""" - if parent is None: - parent = QtCore.QModelIndex() - - if self._row_depth <= 0: - return super().hasChildren(parent) - - if not parent.isValid(): - return self.rowCount() > 0 - - # Check if this is a top-level row that's expandable - if parent.internalId() == 0: - parent_row = parent.row() - return parent_row in self._expandable_rows - - # Child rows don't have children in this implementation - return False - def _rebuild(self) -> None: self.beginResetModel() self._max_depth = 0 diff --git a/flatten_proxy_demo.png b/flatten_proxy_demo.png index 32cd3d7ce635821730b6249d8b84ddc275e3cfac..b4540600bf9b2c970a0bfe34191a3c2328cf8dda 100644 GIT binary patch delta 35943 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 delta 38105 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 Date: Sat, 5 Jul 2025 15:25:54 -0400 Subject: [PATCH 52/70] less code --- flat.py | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/flat.py b/flat.py index 64a94bad5..611251a35 100644 --- a/flat.py +++ b/flat.py @@ -6,7 +6,7 @@ from PyQt6 import QtCore, QtGui, QtWidgets -class FlattenModel(QtCore.QIdentityProxyModel): +class FlattenModel(QtCore.QSortFilterProxyModel): """A proxy model that flattens a tree model into a table view with expandable rows. This model allows you to specify a row depth, which determines how many rows are @@ -23,7 +23,6 @@ def __init__( # the row depth determines how many rows we show in the flattened view # for example, if row_depth=0, we revert to the original model, self._row_depth = row_depth - self._rows: list[QtCore.QModelIndex] = [] # Store which rows are expandable and their children self._expandable_rows: dict[int, list[list[tuple[int, int]]]] = {} @@ -60,29 +59,15 @@ def rowCount(self, parent: QtCore.QModelIndex | None = None) -> int: return len(self._row_paths) # For a parent row, return number of children if expandable - parent_row = parent.row() - if parent_row in self._expandable_rows: - return len(self._expandable_rows[parent_row]) + if parent.internalId() == 0: # Top-level row + parent_row = parent.row() + if parent_row in self._expandable_rows: + return len(self._expandable_rows[parent_row]) return 0 def hasChildren(self, parent: QtCore.QModelIndex | None = None) -> bool: """Return whether the given index has children.""" - if parent is None: - parent = QtCore.QModelIndex() - - if self._row_depth <= 0: - return super().hasChildren(parent) - - if not parent.isValid(): - return self.rowCount() > 0 - - # Check if this is a top-level row that's expandable - if parent.internalId() == 0: - parent_row = parent.row() - return parent_row in self._expandable_rows - - # Child rows don't have children in this implementation - return False + return bool(self.rowCount(parent)) def columnCount(self, parent: QtCore.QModelIndex | None = None) -> int: """Return the number of columns for the given parent.""" @@ -386,6 +371,8 @@ def __init__(self) -> None: self.proxy.setSourceModel(src_model) self.tree2 = tree2 = QtWidgets.QTreeView() + tree2.setAlternatingRowColors(True) + # tree2.setSortingEnabled(True) tree2.setModel(self.proxy) tree1.expandAll() @@ -398,7 +385,6 @@ def __init__(self) -> None: "Rows = level C (depth 2)", ] ) - depth_selector.setCurrentIndex(1) depth_selector.currentIndexChanged.connect(self.proxy.set_row_depth) From 3eeff59098bd0c4333875488851e6a68155e541e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 5 Jul 2025 15:43:40 -0400 Subject: [PATCH 53/70] moew --- flat.py | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/flat.py b/flat.py index 611251a35..a4e99ba79 100644 --- a/flat.py +++ b/flat.py @@ -6,7 +6,7 @@ from PyQt6 import QtCore, QtGui, QtWidgets -class FlattenModel(QtCore.QSortFilterProxyModel): +class FlattenModel(QtCore.QIdentityProxyModel): """A proxy model that flattens a tree model into a table view with expandable rows. This model allows you to specify a row depth, which determines how many rows are @@ -23,8 +23,15 @@ def __init__( # the row depth determines how many rows we show in the flattened view # for example, if row_depth=0, we revert to the original model, self._row_depth = row_depth + # Store which rows are expandable and their children self._expandable_rows: dict[int, list[list[tuple[int, int]]]] = {} + # mapping of depth -> (num_rows, num_columns) + self._num_leaves_at_depth = defaultdict[int, int](int) + # max depth of the tree model + self._max_depth = 0 + # one entry per proxy row + self._row_paths: list[list[tuple[int, int]]] = [] def set_row_depth(self, row_depth: int) -> None: """Set the row depth for the flattened view.""" @@ -242,11 +249,70 @@ def mapFromSource(self, source_index: QtCore.QModelIndex) -> QtCore.QModelIndex: return QtCore.QModelIndex() + def sort( + self, + column: int, + order: QtCore.Qt.SortOrder = QtCore.Qt.SortOrder.AscendingOrder, + ) -> None: + """Sort the flattened view by the specified column.""" + print("Sorting by column:", column, "Order:", order) + return super().sort(column, order) + + def sort( + self, + column: int, + order: QtCore.Qt.SortOrder = QtCore.Qt.SortOrder.AscendingOrder, + ) -> None: + """Sort the proxy model by the specified column.""" + if self._row_depth <= 0: + return super().sort(column, order) + + if column < 0 or column >= self.columnCount(): + return # Invalid column + + # Emit layoutAboutToBeChanged signal + self.layoutAboutToBeChanged.emit() + + # Store current persistent indexes (for advanced proxy models) + # For simplicity, we'll skip this for now + + # Sort our _row_paths based on the data in the specified column + def get_sort_key(row_index: int) -> str: + """Get the sort key for a row at the given index.""" + proxy_idx = self.index(row_index, column) + data = self.data(proxy_idx, QtCore.Qt.ItemDataRole.DisplayRole) + return str(data) if data is not None else "" + + # Create list of (original_index, sort_key) pairs + indexed_rows = [(i, get_sort_key(i)) for i in range(len(self._row_paths))] + + # Sort by the sort key + reverse_order = order == QtCore.Qt.SortOrder.DescendingOrder + indexed_rows.sort(key=lambda x: x[1], reverse=reverse_order) + + # Reorder _row_paths and _expandable_rows based on sorted order + old_row_paths = self._row_paths.copy() + old_expandable_rows = self._expandable_rows.copy() + + self._row_paths = [] + self._expandable_rows = {} + + for new_index, (old_index, _) in enumerate(indexed_rows): + # Copy the row path + self._row_paths.append(old_row_paths[old_index]) + + # Copy expandable rows mapping with new index + if old_index in old_expandable_rows: + self._expandable_rows[new_index] = old_expandable_rows[old_index] + + # Emit layoutChanged signal + self.layoutChanged.emit() + def _rebuild(self) -> None: self.beginResetModel() self._max_depth = 0 # one entry per proxy row - self._row_paths: list[list[tuple[int, int]]] = [] + self._row_paths = [] # mapping of depth -> (num_rows, num_columns) self._num_leaves_at_depth = defaultdict[int, int](int) self._expandable_rows = {} @@ -302,6 +368,53 @@ def _collect_model_shape( if depth < self._row_depth and model.hasChildren(child): self._collect_model_shape(model, child, depth + 1, pair_path) + def sort( + self, + column: int, + order: QtCore.Qt.SortOrder = QtCore.Qt.SortOrder.AscendingOrder, + ) -> None: + """Sort the proxy model by the specified column.""" + if self._row_depth <= 0: + return super().sort(column, order) + + if column < 0 or column >= self.columnCount(): + return # Invalid column + + # Emit layoutAboutToBeChanged signal + self.layoutAboutToBeChanged.emit() + + # Sort our _row_paths based on the data in the specified column + def get_sort_key(row_index: int) -> str: + """Get the sort key for a row at the given index.""" + proxy_idx = self.index(row_index, column) + data = self.data(proxy_idx, QtCore.Qt.ItemDataRole.DisplayRole) + return str(data) if data is not None else "" + + # Create list of (original_index, sort_key) pairs + indexed_rows = [(i, get_sort_key(i)) for i in range(len(self._row_paths))] + + # Sort by the sort key + reverse_order = order == QtCore.Qt.SortOrder.DescendingOrder + indexed_rows.sort(key=lambda x: x[1], reverse=reverse_order) + + # Reorder _row_paths and _expandable_rows based on sorted order + old_row_paths = self._row_paths.copy() + old_expandable_rows = self._expandable_rows.copy() + + self._row_paths = [] + self._expandable_rows = {} + + for new_index, (old_index, _) in enumerate(indexed_rows): + # Copy the row path + self._row_paths.append(old_row_paths[old_index]) + + # Copy expandable rows mapping with new index + if old_index in old_expandable_rows: + self._expandable_rows[new_index] = old_expandable_rows[old_index] + + # Emit layoutChanged signal + self.layoutChanged.emit() + def build_tree_model() -> QtGui.QStandardItemModel: """Create a simple 5-level tree: A-i / B-j / C-k / D-l / E-m.""" @@ -372,7 +485,7 @@ def __init__(self) -> None: self.tree2 = tree2 = QtWidgets.QTreeView() tree2.setAlternatingRowColors(True) - # tree2.setSortingEnabled(True) + tree2.setSortingEnabled(True) tree2.setModel(self.proxy) tree1.expandAll() From ad510d6d1fbc4e094f2a0ee81077220e947ea8fa Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 5 Jul 2025 15:47:35 -0400 Subject: [PATCH 54/70] refactor: replace FlattenModel with TreeFlatteningProxy for improved tree model handling --- examples/flat.py | 124 ++++++++ .../_models/_tree_flattening.py | 286 ++++-------------- 2 files changed, 180 insertions(+), 230 deletions(-) create mode 100644 examples/flat.py rename flat.py => src/pymmcore_widgets/_models/_tree_flattening.py (59%) diff --git a/examples/flat.py b/examples/flat.py new file mode 100644 index 000000000..bd532d376 --- /dev/null +++ b/examples/flat.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from PyQt6 import QtCore, QtGui, QtWidgets + +from pymmcore_widgets._models._tree_flattening import TreeFlatteningProxy + + +def build_tree_model() -> QtGui.QStandardItemModel: + """Create a simple 5-level tree: A-i / B-j / C-k / D-l / E-m.""" + model = QtGui.QStandardItemModel() + model.setHorizontalHeaderLabels(["Name"]) + + item_a0 = QtGui.QStandardItem("A0") + model.appendRow(item_a0) + item_a1 = QtGui.QStandardItem("A1") + model.appendRow(item_a1) + + item_b00 = QtGui.QStandardItem("B00") + item_a0.appendRow(item_b00) + item_b01 = QtGui.QStandardItem("B01") + item_a0.appendRow(item_b01) + + item_b10 = QtGui.QStandardItem("B10") + item_a1.appendRow(item_b10) + item_b11 = QtGui.QStandardItem("B11") + item_a1.appendRow(item_b11) + + item_c000 = QtGui.QStandardItem("C000") + item_b00.appendRow(item_c000) + item_c001 = QtGui.QStandardItem("C001") + item_b00.appendRow(item_c001) + + item_c111 = QtGui.QStandardItem("C111") + item_b11.appendRow(item_c111) + + return model + + +def print_model( + model: QtCore.QAbstractItemModel, + parent: QtCore.QModelIndex | None = None, + depth: int = 0, +) -> None: + """Print the model structure to the console.""" + if parent is None: + parent = QtCore.QModelIndex() + + rows = model.rowCount(parent) + for r in range(rows): + child = model.index(r, 0, parent) # tree is in column 0 + # print an ascii tree + print(" " * depth + f"- {model.data(child)}") + print_model(model, child, depth + 1) + + +class MainWindow(QtWidgets.QWidget): + """Main demo window.""" + + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("Flatten proxy demo") + + # source tree + src_model = build_tree_model() + print_model(src_model) + + tree1 = QtWidgets.QTreeView() + tree1.setModel(src_model) + tree1.expandAll() + + # proxy + table view + self.proxy = TreeFlatteningProxy(row_depth=0) + self.proxy.setSourceModel(src_model) + + self.tree2 = tree2 = QtWidgets.QTreeView() + tree2.setAlternatingRowColors(True) + tree2.setSortingEnabled(True) + tree2.setModel(self.proxy) + tree1.expandAll() + + # depth selector + self.combo = depth_selector = QtWidgets.QComboBox() + depth_selector.addItems( + [ + "Rows = level A (depth 0)", + "Rows = level B (depth 1)", + "Rows = level C (depth 2)", + ] + ) + + depth_selector.currentIndexChanged.connect(self.proxy.set_row_depth) + + # layout + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(QtWidgets.QLabel("Source tree")) + layout.addWidget(tree1, 2) + layout.addWidget(QtWidgets.QLabel("Flattened table (sortable)")) + layout.addWidget(tree2, 3) + layout.addWidget(QtWidgets.QLabel("Choose row depth")) + layout.addWidget(depth_selector) + + +def main() -> None: + """Run the demo application.""" + import sys + + app = QtWidgets.QApplication(sys.argv) + win = MainWindow() + win.resize(800, 600) + win.show() + + # PROGRAMMATICALLY INTERACT HERE + + # Expand all in the tree view to show hierarchical structure + win.combo.setCurrentIndex(1) # Set to depth 1 to show the improved layout + win.tree2.expandAll() + + win.grab().save("flatten_proxy_demo.png", "PNG") + # sys.exit(app.processEvents()) + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/flat.py b/src/pymmcore_widgets/_models/_tree_flattening.py similarity index 59% rename from flat.py rename to src/pymmcore_widgets/_models/_tree_flattening.py index a4e99ba79..5aa017cf6 100644 --- a/flat.py +++ b/src/pymmcore_widgets/_models/_tree_flattening.py @@ -3,10 +3,16 @@ from collections import defaultdict from typing import Any -from PyQt6 import QtCore, QtGui, QtWidgets +from qtpy.QtCore import ( + QAbstractItemModel, + QIdentityProxyModel, + QModelIndex, + QObject, + Qt, +) -class FlattenModel(QtCore.QIdentityProxyModel): +class TreeFlatteningProxy(QIdentityProxyModel): """A proxy model that flattens a tree model into a table view with expandable rows. This model allows you to specify a row depth, which determines how many rows are @@ -16,9 +22,7 @@ class FlattenModel(QtCore.QIdentityProxyModel): expand and collapse child rows based on the specified depth. """ - def __init__( - self, row_depth: int = 0, parent: QtCore.QObject | None = None - ) -> None: + def __init__(self, row_depth: int = 0, parent: QObject | None = None) -> None: super().__init__(parent) # the row depth determines how many rows we show in the flattened view # for example, if row_depth=0, we revert to the original model, @@ -43,23 +47,23 @@ def set_row_depth(self, row_depth: int) -> None: def headerData( self, section: int, - orientation: QtCore.Qt.Orientation, - role: int = QtCore.Qt.ItemDataRole.DisplayRole, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, ) -> Any: """Return the header data for the given section and orientation.""" if ( - orientation == QtCore.Qt.Orientation.Horizontal - and role == QtCore.Qt.ItemDataRole.DisplayRole + orientation == Qt.Orientation.Horizontal + and role == Qt.ItemDataRole.DisplayRole ): return f"Level {section}" # Level 0 = root, Level N = leaf return super().headerData(section, orientation, role) - def rowCount(self, parent: QtCore.QModelIndex | None = None) -> int: + def rowCount(self, parent: QModelIndex | None = None) -> int: """Return the number of rows under the given parent.""" if parent is None: - parent = QtCore.QModelIndex() + parent = QModelIndex() if self._row_depth <= 0: - return super().rowCount(parent) + return int(super().rowCount(parent)) if not parent.isValid(): # Top-level: return number of primary flattened rows @@ -72,22 +76,22 @@ def rowCount(self, parent: QtCore.QModelIndex | None = None) -> int: return len(self._expandable_rows[parent_row]) return 0 - def hasChildren(self, parent: QtCore.QModelIndex | None = None) -> bool: + def hasChildren(self, parent: QModelIndex | None = None) -> bool: """Return whether the given index has children.""" return bool(self.rowCount(parent)) - def columnCount(self, parent: QtCore.QModelIndex | None = None) -> int: + def columnCount(self, parent: QModelIndex | None = None) -> int: """Return the number of columns for the given parent.""" if parent is None: - parent = QtCore.QModelIndex() + parent = QModelIndex() if self._row_depth <= 0: - return super().columnCount(parent) + return int(super().columnCount(parent)) # For flattened view, show columns up to row_depth + 1 # This makes row_depth=1 show 2 columns, row_depth=2 show 3 columns, etc. return self._row_depth + 1 - def setSourceModel(self, source_model: QtCore.QAbstractItemModel | None) -> None: + def setSourceModel(self, source_model: QAbstractItemModel | None) -> None: """Set the source model and rebuild the flattened view.""" super().setSourceModel(source_model) if source_model: @@ -98,20 +102,20 @@ def index( self, row: int, column: int, - parent: QtCore.QModelIndex | None = None, - ) -> QtCore.QModelIndex: + parent: QModelIndex | None = None, + ) -> QModelIndex: """Returns the index of the item specified by (row, column, parent).""" if parent is None: - parent = QtCore.QModelIndex() + parent = QModelIndex() if self._row_depth <= 0: return super().index(row, column, parent) if not parent.isValid(): # Top-level rows (the primary flattened rows) if row < 0 or row >= len(self._row_paths): - return QtCore.QModelIndex() + return QModelIndex() if column < 0 or column >= self.columnCount(): - return QtCore.QModelIndex() + return QModelIndex() return self.createIndex(row, column, 0) # 0 indicates top-level else: # Child rows (expanded children of a parent row) @@ -119,48 +123,48 @@ def index( if parent_row in self._expandable_rows: children = self._expandable_rows[parent_row] if row < 0 or row >= len(children): - return QtCore.QModelIndex() + return QModelIndex() # For child rows, check against the child path length, not _max_depth child_path = children[row] max_child_col = len(child_path) - 1 if column < 0 or column > max_child_col: - return QtCore.QModelIndex() + return QModelIndex() # Add bounds checking for parent_row to prevent overflow if parent_row < 0 or parent_row >= len(self._row_paths): - return QtCore.QModelIndex() + return QModelIndex() # Use parent_row + 1 as internal pointer to identify this is a child return self.createIndex(row, column, parent_row + 1) - return QtCore.QModelIndex() + return QModelIndex() - def parent(self, index: QtCore.QModelIndex) -> QtCore.QModelIndex: # type: ignore [override] + def parent(self, index: QModelIndex) -> QModelIndex: """Returns the parent of the given child index.""" if self._row_depth <= 0: return super().parent(index) if not index.isValid(): - return QtCore.QModelIndex() + return QModelIndex() internal_ptr = index.internalId() if internal_ptr == 0: # This is a top-level row, so no parent - return QtCore.QModelIndex() + return QModelIndex() else: # This is a child row, parent is at internal_ptr - 1 parent_row = internal_ptr - 1 # Add bounds checking to prevent overflow if parent_row < 0 or parent_row >= len(self._row_paths): # Invalid parent row, return invalid index - return QtCore.QModelIndex() + return QModelIndex() # Return parent at column 0 for tree structure return self.createIndex(parent_row, 0, 0) - def mapToSource(self, proxy_index: QtCore.QModelIndex) -> QtCore.QModelIndex: + def mapToSource(self, proxy_index: QModelIndex) -> QModelIndex: """Map from the flattened view back to the source model.""" if self._row_depth <= 0 or not (src_model := self.sourceModel()): return super().mapToSource(proxy_index) if not proxy_index.isValid(): - return QtCore.QModelIndex() + return QModelIndex() row, col = proxy_index.row(), proxy_index.column() internal_ptr = proxy_index.internalId() @@ -168,13 +172,13 @@ def mapToSource(self, proxy_index: QtCore.QModelIndex) -> QtCore.QModelIndex: if internal_ptr == 0: # Top-level row: navigate to the column depth requested if row >= len(self._row_paths): - return QtCore.QModelIndex() + return QModelIndex() path = self._row_paths[row] if col >= len(path): # beyond recorded depth - return QtCore.QModelIndex() + return QModelIndex() - src = QtCore.QModelIndex() + src = QModelIndex() for r, c in path[: col + 1]: src = src_model.index(r, c, src) return src @@ -182,33 +186,33 @@ def mapToSource(self, proxy_index: QtCore.QModelIndex) -> QtCore.QModelIndex: # Child row: these are the deeper children (C-level nodes) parent_row = internal_ptr - 1 if parent_row not in self._expandable_rows: - return QtCore.QModelIndex() + return QModelIndex() children = self._expandable_rows[parent_row] if row >= len(children): - return QtCore.QModelIndex() + return QModelIndex() path = children[row] # For children, we have different behavior per column: if col == 0: # Column 0: Don't show anything for child rows (they should be indented) - return QtCore.QModelIndex() + return QModelIndex() elif col == self._row_depth: - # Target depth column: show the full child data (navigate the complete path) - src = QtCore.QModelIndex() + # Target depth column: show the full child data (navigate the full path) + src = QModelIndex() for r, c in path: src = src_model.index(r, c, src) return src else: # Other columns: no data - return QtCore.QModelIndex() + return QModelIndex() - def mapFromSource(self, source_index: QtCore.QModelIndex) -> QtCore.QModelIndex: + def mapFromSource(self, source_index: QModelIndex) -> QModelIndex: """Map from source model back to proxy model.""" - if self._row_depth <= 0 or not (src_model := self.sourceModel()): + if self._row_depth <= 0 or not (self.sourceModel()): return super().mapFromSource(source_index) if not source_index.isValid(): - return QtCore.QModelIndex() + return QModelIndex() # Build the path from root to this source index path = [] @@ -247,66 +251,7 @@ def mapFromSource(self, source_index: QtCore.QModelIndex) -> QtCore.QModelIndex: child_row_in_source, column, proxy_row + 1 ) - return QtCore.QModelIndex() - - def sort( - self, - column: int, - order: QtCore.Qt.SortOrder = QtCore.Qt.SortOrder.AscendingOrder, - ) -> None: - """Sort the flattened view by the specified column.""" - print("Sorting by column:", column, "Order:", order) - return super().sort(column, order) - - def sort( - self, - column: int, - order: QtCore.Qt.SortOrder = QtCore.Qt.SortOrder.AscendingOrder, - ) -> None: - """Sort the proxy model by the specified column.""" - if self._row_depth <= 0: - return super().sort(column, order) - - if column < 0 or column >= self.columnCount(): - return # Invalid column - - # Emit layoutAboutToBeChanged signal - self.layoutAboutToBeChanged.emit() - - # Store current persistent indexes (for advanced proxy models) - # For simplicity, we'll skip this for now - - # Sort our _row_paths based on the data in the specified column - def get_sort_key(row_index: int) -> str: - """Get the sort key for a row at the given index.""" - proxy_idx = self.index(row_index, column) - data = self.data(proxy_idx, QtCore.Qt.ItemDataRole.DisplayRole) - return str(data) if data is not None else "" - - # Create list of (original_index, sort_key) pairs - indexed_rows = [(i, get_sort_key(i)) for i in range(len(self._row_paths))] - - # Sort by the sort key - reverse_order = order == QtCore.Qt.SortOrder.DescendingOrder - indexed_rows.sort(key=lambda x: x[1], reverse=reverse_order) - - # Reorder _row_paths and _expandable_rows based on sorted order - old_row_paths = self._row_paths.copy() - old_expandable_rows = self._expandable_rows.copy() - - self._row_paths = [] - self._expandable_rows = {} - - for new_index, (old_index, _) in enumerate(indexed_rows): - # Copy the row path - self._row_paths.append(old_row_paths[old_index]) - - # Copy expandable rows mapping with new index - if old_index in old_expandable_rows: - self._expandable_rows[new_index] = old_expandable_rows[old_index] - - # Emit layoutChanged signal - self.layoutChanged.emit() + return QModelIndex() def _rebuild(self) -> None: self.beginResetModel() @@ -322,13 +267,13 @@ def _rebuild(self) -> None: def _collect_model_shape( self, - model: QtCore.QAbstractItemModel, - parent: QtCore.QModelIndex | None = None, + model: QAbstractItemModel, + parent: QModelIndex | None = None, depth: int = 0, stack: list[tuple[int, int]] | None = None, ) -> None: if parent is None: - parent = QtCore.QModelIndex() + parent = QModelIndex() if stack is None: stack = [] @@ -371,11 +316,11 @@ def _collect_model_shape( def sort( self, column: int, - order: QtCore.Qt.SortOrder = QtCore.Qt.SortOrder.AscendingOrder, + order: Qt.SortOrder = Qt.SortOrder.AscendingOrder, ) -> None: """Sort the proxy model by the specified column.""" if self._row_depth <= 0: - return super().sort(column, order) + return super().sort(column, order) # type: ignore[no-any-return] if column < 0 or column >= self.columnCount(): return # Invalid column @@ -387,14 +332,14 @@ def sort( def get_sort_key(row_index: int) -> str: """Get the sort key for a row at the given index.""" proxy_idx = self.index(row_index, column) - data = self.data(proxy_idx, QtCore.Qt.ItemDataRole.DisplayRole) + data = self.data(proxy_idx, Qt.ItemDataRole.DisplayRole) return str(data) if data is not None else "" # Create list of (original_index, sort_key) pairs indexed_rows = [(i, get_sort_key(i)) for i in range(len(self._row_paths))] # Sort by the sort key - reverse_order = order == QtCore.Qt.SortOrder.DescendingOrder + reverse_order = order == Qt.SortOrder.DescendingOrder indexed_rows.sort(key=lambda x: x[1], reverse=reverse_order) # Reorder _row_paths and _expandable_rows based on sorted order @@ -414,122 +359,3 @@ def get_sort_key(row_index: int) -> str: # Emit layoutChanged signal self.layoutChanged.emit() - - -def build_tree_model() -> QtGui.QStandardItemModel: - """Create a simple 5-level tree: A-i / B-j / C-k / D-l / E-m.""" - model = QtGui.QStandardItemModel() - model.setHorizontalHeaderLabels(["Name"]) - - item_a0 = QtGui.QStandardItem("A0") - model.appendRow(item_a0) - item_a1 = QtGui.QStandardItem("A1") - model.appendRow(item_a1) - - item_b00 = QtGui.QStandardItem("B00") - item_a0.appendRow(item_b00) - item_b01 = QtGui.QStandardItem("B01") - item_a0.appendRow(item_b01) - - item_b10 = QtGui.QStandardItem("B10") - item_a1.appendRow(item_b10) - item_b11 = QtGui.QStandardItem("B11") - item_a1.appendRow(item_b11) - - item_c000 = QtGui.QStandardItem("C000") - item_b00.appendRow(item_c000) - item_c001 = QtGui.QStandardItem("C001") - item_b00.appendRow(item_c001) - - item_c111 = QtGui.QStandardItem("C111") - item_b11.appendRow(item_c111) - - return model - - -def print_model( - model: QtCore.QAbstractItemModel, - parent: QtCore.QModelIndex | None = None, - depth: int = 0, -) -> None: - """Print the model structure to the console.""" - if parent is None: - parent = QtCore.QModelIndex() - - rows = model.rowCount(parent) - for r in range(rows): - child = model.index(r, 0, parent) # tree is in column 0 - # print an ascii tree - print(" " * depth + f"- {model.data(child)}") - print_model(model, child, depth + 1) - - -class MainWindow(QtWidgets.QWidget): - """Main demo window.""" - - def __init__(self) -> None: - super().__init__() - self.setWindowTitle("Flatten proxy demo") - - # source tree - src_model = build_tree_model() - print_model(src_model) - - tree1 = QtWidgets.QTreeView() - tree1.setModel(src_model) - tree1.expandAll() - - # proxy + table view - self.proxy = FlattenModel(row_depth=0) - self.proxy.setSourceModel(src_model) - - self.tree2 = tree2 = QtWidgets.QTreeView() - tree2.setAlternatingRowColors(True) - tree2.setSortingEnabled(True) - tree2.setModel(self.proxy) - tree1.expandAll() - - # depth selector - self.combo = depth_selector = QtWidgets.QComboBox() - depth_selector.addItems( - [ - "Rows = level A (depth 0)", - "Rows = level B (depth 1)", - "Rows = level C (depth 2)", - ] - ) - - depth_selector.currentIndexChanged.connect(self.proxy.set_row_depth) - - # layout - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(QtWidgets.QLabel("Source tree")) - layout.addWidget(tree1, 2) - layout.addWidget(QtWidgets.QLabel("Flattened table (sortable)")) - layout.addWidget(tree2, 3) - layout.addWidget(QtWidgets.QLabel("Choose row depth")) - layout.addWidget(depth_selector) - - -def main() -> None: - """Run the demo application.""" - import sys - - app = QtWidgets.QApplication(sys.argv) - win = MainWindow() - win.resize(800, 600) - win.show() - - # PROGRAMMATICALLY INTERACT HERE - - # Expand all in the tree view to show hierarchical structure - win.combo.setCurrentIndex(1) # Set to depth 1 to show the improved layout - win.tree2.expandAll() - - win.grab().save("flatten_proxy_demo.png", "PNG") - # sys.exit(app.processEvents()) - sys.exit(app.exec()) - - -if __name__ == "__main__": - main() From 49f0b366099d6c0c20531a6e3e0fee127ab1233c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 6 Jul 2025 08:42:11 -0400 Subject: [PATCH 55/70] Refactor configuration presets and device property selection - Removed the unused ConfigGroupsEditor import from __init__.py and updated the import for ConfigGroupsEditor from the correct module. - Deleted the _config_views.py file, which contained the ConfigGroupsEditor class and related functionality, to streamline the codebase. - Refactored the DevicePropertySelector to utilize a new DeviceTypeFilter class for filtering device types, improving code organization and readability. - Introduced a new _device_type_filter_proxy.py file containing the DeviceTypeFilter class, which manages filtering based on allowed device types and property flags. - Updated the DevicePropertySelector to connect the new filtering logic to the UI components, ensuring proper functionality with the new structure. --- flatten_proxy_demo.png | Bin 51386 -> 0 bytes .../_models/_q_device_prop_model.py | 234 +++++---- .../_models/_tree_flattening.py | 485 +++++++++++------- .../config_presets/__init__.py | 2 +- .../config_presets/_views/_config_views.py | 472 ----------------- .../_views/_device_property_selector.py | 120 ++--- .../_views/_device_type_filter_proxy.py | 85 +++ 7 files changed, 566 insertions(+), 832 deletions(-) delete mode 100644 flatten_proxy_demo.png delete mode 100644 src/pymmcore_widgets/config_presets/_views/_config_views.py create mode 100644 src/pymmcore_widgets/config_presets/_views/_device_type_filter_proxy.py diff --git a/flatten_proxy_demo.png b/flatten_proxy_demo.png deleted file mode 100644 index b4540600bf9b2c970a0bfe34191a3c2328cf8dda..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51386 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%0D^S zZdWv?Lk;LaLt^`Dv*mjt3Se|Xc6N50aFgrG1j|g!`iI9Hcjyq=KgTFW4ky{GYHSpr zzs8@9Fty{T#kP@GehBXf6+V?{Dh+~dGNMjzZ7VyB*lj4!kBQTGQA z9vsv!b|a|b2cPRq1zpLDvyTB0lrj%zW^e|m_}2l1buV%YQ#L2%&Y)yWJv8X-?36Z{ zDpzr_!=hDmv%!FZ_eediQBAkb_TeF(XzaUS9)i^MbpFQ~9cK8$j<-JjT)q?iL9hldwIf=@NV z1*!UdgSP-a0Ked6)+jYX)Q1Y<#>dSe;bIW( zQRYJz-y#ten%Bia^bNIbHmkXGhn87vX?{$w^(K+H@YZeruhvxj?KovdzzTi6=eLh2pkh8y!u#Zf9$e>k^#==n+rWSEbCGkSU31 zFF_3f7DPxzg`Z5rwV1eA-~cu(+r4|Eyv3ZpW?#oDZr5{lO-=xK$OIx_L#W$_n9mhV z$}3QNc1S!Cu^(l&`u%HRP?e@3<(i#w=GWX*q9Vp_A^!k2_HG6*=Ch^c2E?_+b%8!9BJ6<}8s?~D9$AA{3Z7vJ!@n>_P(I`-n`TYF^! zoMwBX+1vpwlaOj0xr8xF_;zLq&job@YPQ|G4FPd$ekCA-A)3-jKontd9$=$r(Q|vAL=FUciGvsHR@;lVRX1c_e zW}q1~LOE;I)y775Tk!|d98s{a@d@n0yPC=*)@p9VHO}*FiqBh79sRxpLVh6BOTj0#0n0%vH1Y2r8%2^qYB0zd zS6U`tQKh4V3m5CzPRi2q3`n#^lq)8A#sRg zLM@9?jIY<4-KXxIk)03Zv4OOC6{v&S0J2nDE?L6Q9R5t6G(u3<(9mdVYMSG%`0(LF zd3o>=6M2=XF(}D=I=TGrpTN%=FY#}d=tfa5XBJz@wy!?X;$v0}S-mm3D-)c(2IZqk2)szs;1LLCycXdEUG`{QYY3j}BiTvi=P#FC~fLD)7F zKX&uk@_)1AIKfH$tv{@{60@si$JB^Qdv_<^O zJQQl+R$N>b!SzbSk0iXI6;6oz5>Ix>Uu#+R3+5mXujkFyhqMRUUkxT#i#10lfv_CF zt+6RXP0^wd8gk0(6?G)aDf0zp4+R45OKGK)oNRK;5G+GfT+f$J=Mya(w23C-g&8Ds zD7~+y&uuxwY~S$^!qbB7@)XFpWfUQJUfizQu`62bA#Ok2qUo^vK@^)CWvP*ttbN7( zvk{brlUibFDI-HhgZ4zzhIq}-@pL697#;;PkT`})XGD;Cqc!Bs0IsKpjmrOCy}qQV zRn+zOWXrhO=;NF4gH@}>L$7f5><1dbuc$t5ljufX=Z7xT1W-p;Qo&aw0)-{Dv}kEz zC~^d0Kc~1g6%4aq4Nrg;4h@)9oS^^-LgCa?HEn>4)iHuctn*nxQ z0e8U^1qKA@TGvQJog^iF+^z^@aDc2q z!DMsIX;$Wt?aLq(HO0rMy#xhmfHxyb<5}oR6i+h?U^iQZU;F)_@99gjFMzO$%D;?? zA~j+NlwsIrr%s(}ibkGR5MLO_1U>Zg+al2j9_e>#q2EN@5%?22VYzejU-gt(-7TE_ zqY89*o~QMktn5xPr)g|b@#S(mAw$p}=dxq^elPXARDBOV15X{PIu_a6OIAOv*lB9= z-+z`cc-Q$ub+!1jS&To1W;&Jx4B8bdxyxG+zkzwaLNM8V{}@q+PP5o|RJ$&YLEYxo z&c0@ugpEs>0l>tS+89Lyw)@9_J%M(|$;X$JOP`hC> z<0uUi*4b_1+W1%Ll0DQRm=rlwZN}X@cj%#;P#k_3PIYas8cYY%fWX`fX&0782&66K&bi)zLxRBAl~L zthV|iMIcE}O$9FinWE}RYBs3~)zA}ATRp<$iz?00CDag4-y+xu3@K#>b^l0=Kbt$L z+@hc>W=euc@$=JerAtiWE>6_X@wTM=(Q+2McQ!+<3khje>)2$2`ON^oYB~@PB&fxw zUvFGPyHh=L8ka7@BT7=^A!P!2eLkw$kS%zvn_iz@f*OM1d*F9A0!T?XYb__H0u*RK zkdi5g4eKm4G_*U>-QC@)6#UtN-*dIB3MEhOd_=Itj}nwoB2GFu%!-v0MHt#mV>svx zMLt5fROXhKlk-`FWVkk(MCN0An*NfeW=qh@;$`X=F6QLOoH!7$m%>so$^PJ{WI(?^ z*i+2zPKN}(jRxsDdC3yyl*2^x?@!qk?CY8tKYko) zAqmFR1`tF6B3cdQ6cuZr{=g8?3tmb@_L@suSPHN^AQJPc5ndpD3nrw-ODdGDb%$le z)}_ncpPr-OypBx-m=3&`Zz>%o9K49ZW^+g3E&@|n;{^FEGCJ$4l}ytt!2LWb_|$CU z{Y_~gVkt9muP6~jG=tQw%r&NwU`>(7tSx+Qegc95%s3tU30fCoVJ-fSDeC<7i>QXA zfCjQ4K@A~~6?>Dr_?&`*E6QzISOfS6;2c#Sj(z)3wi|$nzB&vG{l3Jjr&>{xX8f_dveE)|gp7nL76wK}2t0L2;2o{& znzJm$4NBaxh5f=p#a$PnOe3vj(n&zU*t}^Iw1tS%U>A9aTNTb{mPhUX8jy}jykA0q znIu6A8{Fj5B|k^&xAdpAZJUgUD{g*8HDLGjZ``ku1{unY`a}ZgPM~GB9P_ zKlZ~Db-0}f-{2(Gr|)e`LG`GhKuKA79M5WviBOC@g{4kF3n>{vbS#hC$cCiQjtVU+ zvOP){3t@#MES&$chX_lWTb}~P8XFs!l|UTn?HR>)*+TY z1cZeK*9Eoy8?-TE930V5mZ9&{u%+W(kUR*Rc|FsH@2C1f9x(|;6OQIi;UyT%X@pUCd+mZWk~tG<$3g4*E2qrEk|L`9uhjg~8FM*Y=azkWqZ zpp!tMi*sxA%a^Bap?YJf)&}UhutW^b#m~#>CGc4U$Qu2&6{^Qm(p*ydV-q0mll5TH zvb^ zz}O7s^PUD8DIe5#*(~GilWx*4fF-d9XxS39bF7BHzjjPGz{$yU?AmwiUT0Jku^bNu z-z$0fTgd9{9RidweE`N8B0_l*s{c*7>W@SchUl>1eA9uxTx6N0c}X0w`;XG zDOI6OFSKw>aV7BAmG#dZf>#p)JWM_Y`-9wpo|tQdS*C+cGQe}Kl{M%ILI96P#b9o8 z$jn$5pGNA7XU}#C2u!$dmN$h6*hxt-ZQmB#S}$Z;mHQn4M^ve)UObcV%ERl%KPIHT zly@&Z-N{CZi~lU=LxSXa1bmw;d?nR+Gm5CS17r=p+3gb9pdyBkf%6A25vWoU{~nN{ zXkj*R2*u4xD%H>pkZQr~?6HQ4;a05P0jw3(Iwpy22lY>Y6L3v9lItteu2B6N^xu=r z{Nmf7onG?XL!s+oo+%Nsg8WIy$>VF-8Jn z!<>vT|0kP8TcI;UmXN~DXh~B_PteySy+f718K5&GG+SF^Pv=YBbvE9DnxU-}R zhq5=c2HbPVJ!1)KfCWyqHf&k|H~+*x*@$xx9z9ivLjGAq+daP>5A^Oo)6?3O{}Vm! z{50{9{P5jG>P2sPiX^s0liIPE^Q_cQR3M2+MLg_qcBGWC%t59-E-EUjx_z~};&~NC zq!bBiY-4Q5Gcq#qqlIL`I76toT>SqlnQS$waGsDOYX_6$wFasjDGKAB5%9)Acgd=T zNQLr()Ob2zDT-u`U|=lbdycX$II)pW`|K|EzJRWaQ3$c;uUdhiK|Xzr3_b*2`dD5l z7FA+4ccdzAjnqp3K#udFjZU~^T(S;6?#B6Z(^POr_RssIlov4qV|v6%KUt^@S7B^q z(DPcE09iH_4&1e|GUWZ|_7=w(fU2l$99o`r4VD(d?1B+^M>r$z$4 zRRZtdrqjiEfUx`VEG`VWvihvuu)otPn>WJ- z^Tk2I1?!zEz8@C_6Wa&?W8F5-s01Wgjuv44iT#}ZKZE5-|L+>So*(g->PzCHy zdHL+w_m?48dqEWZbjd87Kd?%Dt`nQ!t`8%x_A#JrtJNqJftRzQB?1LP5LH6ZZnf;f zyNlUm+$KH}RTJGVbR`eiBvHQKvM5mPosaZG!QR+bztQp7>ml^ILB&u;Wg3|B95?Wq;KeW;F6V?XJlpu(wEk6JhBY{K77QIUz7!bhowE#r+>siU-2j-RTkRyz&2 z93eiz=)h?=VOpeuL(I%0$LnoEXFD!|{LLBca4o=BPqAkRxw1`#>UkuQvR2bR|tByz9S@0>saXQ57`R1_T;7VTXQqALsqIM|`Nr$|8~tIcH&Fdq@*I;?go(u6=# zxieXNIUdJ)#1A-ie8hM!0zbir)a^(!_N5pgVgWGy&Pj|oZk#F?{-AV&SRrUKI8VdN zfj^WKEiUS75C<9sRc_uV+lT@ILk8IiCunGs+Z_`0a%|~6gKd~QsF$s8rnr+h11NHuKis7(L+9&^QL7WX9KRtbX zM+GM5F1#Mo-S{vfTQpZ5d7GQ-Y1{wkL;WYUTTWPVii;QJQv5cJd_=nGq+u1C$3tyk z({|{IO8Ee#%NBhps5&CJI)NIswmSbKR@}-BnNfUcOq`hOk%%L(%@BH7pc}yPsj{@Cc!tLD`BI_ojDWp){+vrG*+pP^Z{VrUjs}*EJ4}V4XNAVNEoV#ENg=$9kSS4 zPU77}konco(f{)ctf6F)Ab~>4CEB1`k}im?%lE(55>E?iD@doUHVrx|vo=(2OVzDm zWhyEIORP&fPtYIapi@)~J?ZsUN%>&er}U5;-VX60J%VW;V$Y^k_C2|9++A|l%7}|Bb4us-S)}w6wNXR+}E8d~KSy zPcGOG_8!QKU^kUv!h&sYA|oSF{^MmI92^7?{@jc*!5oZ`-e(*f9Q5_|KeSTzB2}>= zTQR2s!YK51c6JnZUk)rTE<)`YL|x?M+NWa9npb4Q#lF4!RrJf}KC5_ZQ=>k+`gBUe zGSvw+>f~~t?(Xizg*grwkB%L?w?k0=GV)Z%Zt-0)B@n6{;*kC9r*d<0yw1qV-kbc3 z5*?|i7QS=23)#SgjJO#y08jz4s9osPWXcb5{U$2a@WDw7g{(_fKJTxF94fU(Sa=RC zYAh@)u5lCriHfS!hf1bMR?WNtlhg6**VYMTwB51)=K^U`IvOO|v4v zMefPLlUKI+yu>sCoVvVW+kycwY0{KQ=M_j9wu zCc^buk^Le+Q)EQM=%CNNds}W(Kq?hg0edm03Dz6RAW|=&W(KfEd0c?4j=zNgL7jz# zmz}$fLSv}Ly{O_vK79Phe8gB*RW-oVdZ_i==pW8bPE1EF_OrvVh2H1vo8iR2v%_9zjw?DGwtH z*+pRg{um%UNIKB{#c%w&=V^^#iw&{&`fkc)sa!Wsuj3 z0fl(5hD_~QbBh!&;G}|5Dtdnr=oo^o3rU@D#6#e}jU;k=@!gm&`C^b32Pl1_SFJ!yy;@)94j|nDa|MJFcu+6H6p>L*02;H*yzN>x7j<$SntZp?%ckOJfk3mN?aM_i>*mcZO13b^k=^i0 zI|mZa-sW)Zm8asax>CT^>FRhQ!4?J0r zl_jr0BSz-^2|9i%G6m@$XdUR0^VRz03F_XtLe)T9TU+YIvkj!`+?(eTmpW{A+xN9~ zl&z1iuL5?@h%V7qup+CelUbfW+fZSxYjF038v9{`l2sW7{7jZ#v+0?cnd#|MNDRGP z4=deucy&r$q?d1&nZ4M7AE^U?qOnnZX7`&o?5DHQYRIRs;~u)4kCF_y3KAqhWewIC zbmFaZHp@3Sep~u0&*x_+{}9+BbIzz=C6N^+HY!07Psr^DP) zeqXpszkT~gavCODwLEZnUF_eyN?lq%*uej+!(MivRD>mVV&MH;B((f+J%%(15g5`K z+?=g#W_226AeD6&LOaS0+G)meXokFaF(NJP23RclC(q6jHjlsWBlVI_ zPhcy67y{@U1rTo9(C6W~YSk)atpfGIn8f-0y&wxkJartsY-`c)y z+cf-V9iqLY{6{*^$9~DtAJ`x2a7hj=G%!R=!5u)3-38{H)#P982AY%3DK_?FsV7Xi^4 zxyzqZYl%9o1NeEs%~=g(y9A=PzZDRg(Uf4Y8ra!B&ykRvD$n$Zk`b-m8u z8*q3?Y-UE2D+y{JYOy(Ca-BfKL6<15|BsPWDxy)$pwBU0IacXY5Krs~ zCG@5XiBoQT|8ZJcS`@esZQHP#0g)7n=CNbP{EU;wNp>kI^jb=%pe zaco2%0v>xr#D0({q;TS`v@(`4U-`!$C;=a%SbrZqo^V|A)%DeU1xgUpKh z`b<<=QK{Mg_&$R>)p~Yz^mLiOsi=s5aM*^KDsKyids=+>}oZ2O3yfwxOAC;`z}qLmK=V zIVFw;pfPoT6czyowv!JjztYyYf`y%D;kb+v^nqsC*_+E%QGz|t@|5ycswZs4e|vhy z$HzDC`VA%=-mnkS(f53MeygCs84W%Ucb~@b7|?g0SZd&?4^z_^&v9%lNK;WQ%d2wO z{o<;vQc~D20t(9_ey zW`{M6KO2J9cU1=MX29V>O>B3&WrrMz5!?n z_v7`fq7KSZFa&DsQ!Cpoed?5MuJr*iu|i}wQq7HgMS1tlM;`9(K<|Eabrr{c5BQ0k z;XP|89Y2`5z3P;PRlNECTS1v}_x}_Wns%U%R-Kcb{RbMT1-ETLpv=w4u+gxK?l&?r z0_ZF^<)59MB@Vt9d|7BasH~K=v>d{@JA)5P_bw1cPc=!GhkeD071;fyE{jSRYFXpX zD7>!3c?>%l85HbnXdH(_{)q5yiF;$HBnL2?2SL+P>$u2ZK=*UyS4K-xfGWA&FWH{efF zRqedF#X!+DhFx^^5l}TI94~|zhp1(@xRJ87{rHSTRuWAL@t5aCadWfgYIWDoh_$@B z`Q05I?KqnL`RKg&LVXDl-l@;HJn#RP^-p zM8}cbQ<0nm%DGciC+NE`u6us#mgLC`5h5ZY-kqD^CHMkQ4>;Tt!OQzEHR!zo%`cky zwS(RaXMgDJDh@_6faMg$D<@~?icF5qzK5d!HLqW{x4*dq_)aX{8o#NOjQ`JxTe3J= zVwZ@>JZjH-trtNJkd96PEpu>aDJ=mm!L9l&=SK8!Dy$4hFF2b8ZNmbvhYbI~Zo<(V z=pC#0EYBzn}t@eyG1@d)q5-E~!mZ`?>a6K-Bg5gNn0s>8+gUeGd5d?Y>JsIs#T$+>iBX~rr= zfgzN$iZ9dBPU#uiWb{};SmeTT{Ox>)?u@Lk4b&9kdqyx8=_>%mwX{3w9>VwC0OF3n zB5Ch;3j77`2=C)LC>0POiJt|@c>-m5#GRJ{sO~D3ty#UAm^C5Gz=Yuf0StM$AU_{l zAvCKC=mWSIlb4f~Wk<3;40O0-H06Mtbn&#p;fc8vf!}jsf%HZLGkyR59m9EgQ4AxR zh`wRik6tY96fpPe?Sg)u9*kwwTHpxmi7xV7%m6X>p`HUBZ@rKSMj6OR7ik}A@*FlK zygOj(Q$ua8mrT4J8fK8913lrSK63y5ed9b*zyUMF#qe;k;5(QoSjzI9W~^EthhnXv zP>SmhkBt#RfoM%^MHfrgrKQ3Z_X48?H3tolsygz0C`Ws zhJA9h6%Y_#jn%LKF&CJEgB^?G|w=(w@D8zR~TSp4$Laf&Jjg5 zO|}np0UK~{@F!#~##YvfAt60XJ`iS%C!F}7EgAr6&=r#~SW#ZiDJUoia|HSzdcNk2 z5i3Be0-@|}_qZi+L0#FCnGcNv8pEeFAL=78T zP78gJ4{E>a=D zt`nT<{hrnC<-_oO)cg;sc&FS zkyRu<+r4|oju5<#a@n4t4X@!=YiVlwh?{y3xN?8zc46Cua%%uM<6v{=xq&RsvXPMy z4J3a|6&#F^hotuG-){=5lxAmVMIb&A^!Tyil`9LxN`L$QFgEr*^ktXKBKD6719u_tXaLgE3m z=wbC@DBj@y(5JNoSbX;-6QWYd#F*^s+nDCtB$Qf6u@YI69JX{3YoGjiUu0i@Rm zIfeaVKoTWmJ~PSP_Lt4nppL+fihF>F0A=fhda=w9$G|={(@h_u$-y%_CkMsaii(Op z#0Qi!LpbAc2975HxTj}gVq#BO!@8!u?4++*TRYSf7KmNDcAXFqL&pj^M+0e6Tl*_H z0S;B*5EdmwTfB_!^t9e;cp~@i+xJQ{qZe03f%E*&+mI|#5@lzHECpz)W+nl)0lrg@ zEQ0NZ(Y%5a{9qbVc7{6p)YFNaI#V$9R46epPz_O$9I^L4$Zm3S5@@h?tFqPg>xT-; zpt)lViO36CWawL7mbnue&46lipq zc|3}aZ!HXZ$%!*5w`_Ugd^Zgy3V0tvAU=a$;EGdd17REw*)qYCk(z2{XD5FBhkMV# zWjVuIo^f|FaL2J-;*b*dcf{anEU_?P{<^UJFC_?nl=1~~6Sv0RI6Ct`pW~j*bKFLI zJXrNpt)=m=YiC|L;JpR83IWR2pw#T7D$(6-n$}0^7W@ScFUoEn zX#c7h;FF9&t4NL_0#3u}S@! zQZP;+R!-z#a31!EmvKRgkB7V~5WQekNpv}JMIb7FcRmgqT15RgNWeU@Lmm|u^wc?3 zTh8b(emiW zc8LYpzStQa<8gEWMM3uAFJJ55MDGSWJGgEHFw3aiCzc_eL>#KP+&N5D&Y-hOy|uL! zm5$1mtA=BqgJ3Oi(d^;ouC(r)xJb{y5Oox{wouNM)S>nG!GmjalUKlV6HB4-C+r&v zJ;rmM92vMSHqSpAlYbzgxH!hvwe>1YeHHZr|q1u+nFfTy9v?4YMt z{Pb5qn+;p7#X0&z%y0IxBMvR0T&3%}Fab{mDrlcRou`k5APY-6G4m`qAZ4pm)Hy;z z7R|}a%X_VVkjbaLy}fxGKYdC@M#jN20xGqFN7`6bCq#}M8N+#SGq+8Brvg-Cm84}| zWGS;fuE4lL6@yCPSZyT9t|nDG2R0C>f>*GA#&qQ4V`RlE1MSp5VUg?T=pZu8Y%{69 zEzl|4+>Jg0EZsQx?RQs?ZHx5|YHVx-x_XVMw&A3T zckc)_hii`1tiuOhd11d{i8j1@XAB;KeBz;;X-MSb zEUOEyOG8V{$FA)H*~d|tsIVtu7e_~%c&uX}OL0LIcst38JxyWf*1DesL6}?{MB~fK zn^_=1mizl1{`7hZe|n~stNwQ3&`u%iGc@AcaV7=gxvbh2A*+rvCr>ks zHi@O_KoL-iJ=rRdytK#P7id`A>YX-15v9;s=F3{02hBTDx*)yDIulRgF_a{F@^(z&fE%qj|jUW^O8HG;nFOi$npB7vi8a_D3Nh@%tEX z9+aXSi}L#Lkeb1Wm=ys{A!9c!f1!?1sd?{IWN$QzeJ5q!i??dUxrlx_>p@w8-P-mv z$>~GcW_KgGo1f;na?gKxuDuK?eB z2x{CLnxTW@HM|Q);L6uUi0!e1n1Y`3C#tt^--h~BTf$}DcP(hOV6R|hRn<`%r25o# z$8SL4L3qF1{0JTw&T0K zfxzPmT=mi)Vh@NEBmLU7cZgorN+VZjDX-WjYlJd3ikvTbFy$GV7ZMQEJ;vJ)wJJ^Y z9a>3zCRFF|`!!IJ6K2(9^2rg1*d91_(evKDc~D-s-5v}bj0e?-_aR*wsFI>j)}`as z$j>s+CK)MmJ}dYRz#dK>M6*}{5D#4PUia_gD8~WA@qFZ605JN8fB=O!bp{m22g)mW z6!Iv_Sfu6*iec0J=_G^ec+F{Zn`Cqph7*=UL*wJ=sh)px1X)9_L7X$x_7+BUtu|mbIQ(YILtyv@ zBt!MV++EFgR(~u^Xuz!?9WLT|NvkL%Zup+iJcEAJN%S{fkb>(Q&lfYVL*|-1dI~knsc~KFgnKa;OFr<1kb>8-cv{vg7oV zZ77kmIq&b)a_B5)5NJySw%OXckFo%bs^D@`za2OPZynM;>boq*XN@t77!rnukRBRa zTJFAwjRV2LSQT8QA;s`?#+n(Z(dk3mScI%&ki#@JU@=HPaMPn-2$Mr#fIdcWmOfwO0b2l8kgg$V?c3c$;;XU`J`VrNIrVLuG` z3Z*gb9q=GhBed=BpPozFpH8uXVHy%MN+`Y6`5WV7fY1;G?|67*Shl&K7@%ng`2&qO z>Brsh9yI=$pNt27aYi8+XIOpmh8=M#t7t*4RK*ylx|#-W^3OLfL6-h5S`cpu_z4H2c^sD!#pZDZY)l>2GNt8j5yP}Tnl!% z@NRdqa)-f(CyXj)2e+mFAYw(BP@Q$H0)jBa;TMY*W?*jU<5L~+?g^^1 z#c0~~H;%x)YhI*;cz&Cfc^09+ljo?jx^zL`--!2;Qu0qlV%I!8fGh)|M-GkE*3prM z&QoT}kBl6JFBM;!PcuWk4ay1RV@a%HIaX^tl}Y6jgQN9lze%%+Tb{JM-)Z@BQqp;T zqztosB&;6w*T?=`YQa9|Dzap@7DkmrIOJppWF%mJeJ;hmMK%8N-f4Y?yO4~q6tNl0 z__rT80}mNE+^t);n#B9pxA|kwP4ueJ$09l)XM4wYp+twkM4T)LQONumBNy#aM+4PI zhCU`F1nSzr@06mAQ*h6n^R6-F*Gbip0x&eZcoA~`4jd%*$)VjC``XaZkj92PJN;k0 z0Ba;6_JB`#*V2HuCqZt(hP``6U;jI4H_G~sfB?e;P7YSa)30ChM-lrPI2ED-;o)&A zeIqVPQO^XU&se_ngnI%J2mL~I2^wEe&yCS>=+Fud3mfR^vBwCa?~*KBX`$sBhEaDC zoqoI5DAq3zKmRy5#X+sioxbVdsQA(y8*bxt1aawwM)u{<5R=dQ<30yDR}Ktxa;S}U z`*w|b=L05?0i`bidcII2?{gdVpjzK~FEFry7`1ShAZq@Q{(ui3fGGb4|Ga&Ll#8C{ zmsWS7{^W%>LFhj#g-AxKIQ8ESu%d%!j1&MsC=cpzk|sJTH>9j{md~%r8UFtuvclb9ksh>D|jM!mr+S~ivEZ1G8@19?(=heFRyN>YhhvSKa z`YK2sP#R-!5F1W2;TFLj>q2k@q{vET=aO1v`B^{#P~W{G+NikL*oe!H@gds6Rb$RS zPsrQNPX50F=&FHar?H7Y6nzx%D}c0BRaIqXWK2vy$zZyu=tt9UA|UTy-Jv)+|h~HS((-9*w+rns-fIry%`!A z3BbLGt=!Vsh!#5|6!6)JSp?*3s|<&t`S`GI7#dumLE+?i7XU4=d`<3!Wkn?a)6f=) ziZD3vP%u`2#ssYbawKvn91ZXkznCOWA%s^bHiPKb>rsUE_7gyXG^ImvQ_djfFW?ra zR{%|@TL85aAq>D6_nKSn)vfS7;ipp$;`ooBKcU-2qFe%;ghM>rLEr%CW1xY{%2q-McxeuU zg3-Zka7FH5Wldt<#?0)Da0gjqdTuU00EQTlY&9eu;2nZ=Aw!^5ZXY}9b4t78xTD!Q z85tSVCLxSfu#RjUm%TIO5AP$u_Q<#8IM#UgD<%whFqM#5Vj}cr2-homO z4*Nm`y<#7TWeX_R;hSCVw^g5bE*!i5M|bfEBwWf_U^aMXvP{au@TT>!5ojw& zbx*~_#8m9WCJG7C=d-G4_&Jyh3JQ*E-nf3m*8Kq`wJYCb*x z?n}3O`JO$ismwzKH;mgT-?_6etK=ZfImfbw;4Kh<+`SR(nlp8jr>Ez}qdwY4_DzM^ z3oa)YPGTWqIJdF>lFs_|ReS+CG}Ko-*Bg8jO~VLf3J3a03w^Zy!rPiryvi@cP5oM?i*OBTEkr4P`l=x*GA@y7p!b zA(?eTIWOgz~J7&^BRB z2B80AfeuktUXUqDe<%o8A3eiNB_%^r?X)ZHU@M=ec&1$K?GpTV-y|1HTanGrKX2s@ z@Xn3M&1T~PvqhQ$BF1XK-j`gP@G?&E5Q+TJ)`7!@C<{8Xs+RyjgrcQ~i1s996rr~< zh~taCgj6nT$3Qtg1C>(hk}^dGwS()vWuVrpq@AVe`Oxs3|GkE+}Y#ryMus72$%# z;Utp3yL%>2nZmNF2ZvHg;oK1ycTD^JyLZB|1UkY~lyT(xZ{EJ0A|1^PEONr)k=$!C zOWvjzxk}_Asom$NwDI%JZ9OEXzB+3C_!e7!33lh{)2GTfF?^=qzOK43FEWnrqgnaL z!Gp%x$&bDj6c&!cGZadPXOaTF2tbB)wuV1N?0O?H@s#rjhstAlbT(lM!n6Jc+T!^6 zZ7z2tI6?Pwa<)O1DW+?#*|_|`j!nD!E-0Vn8_bE&@cny_Hyv?Yi?&YTA=E?%WC2sYF&GQ9}S`|12^e7MZ$3OWNUA$8huvtwS|B z`PT~__m{?ek*^@;kx!mCTjaaF|Km4XHgC3L6J0*t$yR@F%?}0mV#VOWKk&srk$e_d zukE^h#{SyIU6S`aqviXxZ!D^eSowd18~y?ANzk&oSM>Dh2bq)bgsr%bu2wk-U6Lj&;r^3l#c6C_wt z>f{&{Gk$5ifkl||y9~XdCPj`AN8yXxnkvBgEC%^!pE{m8u4lDZu`fBw9#8luqXtO#`df`4Tj z(lVipbIl9tmSiA)x(-W*j%vLCO-4`(7rvIrTi0|nMPWK)#tgn2u9s$T93!ScX~NNV z?Ij92pI??wmLa*FSpvcpAk#3RmzS z8p?uggtfVnB&2to-}ry`?Qfw@+q-uTi5N)&RFyGL{(;25MQX-1G_R|v4F5Sy zo*I9|r3WiXa>Dwo!U~dfuh%jB7L-avvPXMD;Dm+qWAs*oEm3Xl4S=o<=3xX zEwS(WjI)S?QLhv+(0ef4zDTPi?I}~HI0T*A^6k;sOxb=D9$g_8RC1eP`>5d&-;zrc z_pe&{|7e4sDZ-*fO$c6mZWFnn1;Y6qo04*$EE(#l3RIISs8wH{_wkrh$4iREA(A@Ns0AB&5WBqu1I=aToU&YdIs0* zL>iDYJ8b0CBM-U{7*Iq(6iOGC52&)PT5SWKRv|eX498a1-@kXyZcu*>`ycCi_`b&% z=YNe7sh_&3N!Xu24Z0d~N@HHfQTgbK6c1sJGVA>dwFBsFDrL1ID+yngt(Nd~oP~*P zIxCg!fa8B({W%%%S z7=dIJe+`PX5m?fzGT(@rkzE@cXO%d#qb_hE))KE?CFRAbR7Xsn^4OgR?#-~AZZcx!F8V#nC@ zpQvVu5H{KQ{mEy_%E+fnYUYNQuW#6V;BPNovt8uUxs>1Pck%lyBI(bDV>_|idKt%&YL$sT&|#?aDD4OI${Ft(sssMls?1jP21N-H!>H{zST@X6e z%^*gP@IPtdMAMeW#$Ca<#=St&(={K4%);W7y&L5=<29G5Ur?S32?`?VfK((dH-_6F zSx|YpeCk#9l0(Z~-kPXY5QJGJTS@hlS2;MeLfVBJKnSvNf7|!eP8gNu=G>vNfF=Z# z1GB{~JP(@RyZVv7#H>V0>ZgBjib4yQ5Ttp@?0(H}heuF$gnf#jW<($miN3>Q3UKtu zv=ishGskF-ajI-1CQmlkgDmUPVHPU-g7FyZiN-^3?SsF6Mpg3Vd#BfL-(I!N=;h$x z5cP-MxE8#={=EMpzo3A6XSn{KK+NsmKd$7+&mQ~Nl~vTKV@E*oh=>UPjIL&E3op&=|I6kOcZtG&8b|GZoIb#-+T-CN@)sZ;n0GUY^t+#Hsa)wA~f z=Lz(vpd%M^)EL7U%z{Nx%w57~<`)-R=@Cwh6>I4fsz#0Npt$Vs57!9GNlul<vY6WxYcy;)ytWM&Z?&)Z@S~iZ-3eO=O@}uX`2xosoCUTUR}L$f`_zc zU$j)nzf?@+B`5a+-;5D|=^5JL_?O!4zrF?NA7H|N+@KWE9t$;Ih@aCa!=)^)j6_MH zM`~(UnOKbk#9VM+WM9z45g*gxJ&D&>`DcPX)F~u{3t8%*ojnZlNx!>v*mc{c&!$x{ z&uHk#j|}bY?Pp5UKBqKzaB6ODE^H2LJmHNZwMLVLkbRVwkUDQEk(N9gPj`v1^TtQO zE<_B3cSdQY{{IxLdsKk=rm#u`3RyKz#{N|JsAW125ZB_h6fxQlu(!6opFS!aV-_Ax z`@4 z`f*h)7BmMj1!o?abP$W=Ph6R`}wpskHrKk`x^nqlV%5H5*80+d|B_twX`R@ z0G98YizejR4mi35%=EVP>(?*4flNH&H}5q@a%e3+NZ>ZQC~33MkY5{rii| zHz8s(*zWAo_?eGG%liS2+|LV@$u;^PD#Z?x?R<9V!Bn+_e?@RLfLO&`!^ndNW#bp;{BiGIXqTw0gZ6pp z=exVMZ04ad*YV=h;mYu%VCG=k%G*6#Ht#N%grgZ9ZQ7fxgeFqx-CNbu+hjizYN@Zp zSl;N?qsLcv)4FPV`wRPC+}u8@BtFbJN>+Q!f8DZXA9Lb_5u5szyy=c)$;J9ewT-+J zT2`n(R839HkFscF=jw^JFW|fJxrpvv0Wt|)Id1EWS)`m&!M1<4`_Ed04>f$f1dGY> zkt}bzV4!y9k5|2Fj@PpD@H^xi^EQflUH!B6KmD}ujP|mP!qd`bbe}`B$J=ErKwft$ zaCYhiPq60lB$ot>bhV*F^*YDu4dv+!8FCk@?!7`!8*%>d-hzr&NN%B}QYN<36<9ak zTtX40-uNW8c8_DQwZbr<^5?BT;Mq;IZ(Lb%{5m|(%YH?F6CeKtvV{>2!3q075(oG1 zx2JW@Jjo?_s-@(k4cfLb`6Z`mCQUt5;Yd|ZR-d5`2?N}#Uq4sk3h7PQY``f+XF-aS zKLR6yYX#5S&+YeUON@s84zHaCuf2uzU3g%Yt}YGfyZ8Qh&1v+9*P}abAidDPM6SLw zTfJ*X*uZ{7UW+&IJ)-Pb4w*e>q_99Ea%(C>x<|4X)13PKq5L1(__pW1d-En6u+`NZ z$uk>#;Q>jnTX!?807?pRR+D^B;n1xQZ#}U-Y$MTC=z*% zpLOYYjEjJx&EQkfdN|pX`Yh=@Cv6CxCw%4O)04ApM#CgWG>k9`R@nT`kf`uwlUr98z_(;S{YO-Nz)_a`Mj4t6g1PyQV#SXjqXe{4%M=n40Fzu~IKNAgS$m z-RTSao>6)S$C%nrpSEn(v_`<-0}A`RydHL+tD(EfCUo%{wa_*}erqgD{C+n%IZb)Ba`HHT z>8*b#nap3H|H81VN)LSi=IVvcZS(i$dw8CXsdG5n=6>JyA3D3Iw4Tfh*lJxjInRA; z%d2vyGYp-V@(S%J=^D~oC@BCAwr?w;<(71p6iWAp!f;qXK%&;jY`Fbnj~ymcHpTyONNy-<;}sd-v-9g)q-tj z{h^7)-(i6%8#ranQ(b{yHA&61_7cz0im|7Ei3q=?4*}{&ZdF z%4~nJprT{qM!sexB5U!oCq5bjA_YNKlc*$&NF8%WL4-y<_= z6mw<}Xb(#%7PLPT(*ni_*G)02nLle<2z1pofjCGQ_aw1hjJ~yQ5y{1y+ zQF1TUT8woWh?F@75O@RIC!B#FU9#G8X;QgSYja8K3eL#Y<$$A`+vR%y{O4b_05w*@ zo7sXwt{di(u$TbgL7c%%m%JhxOKfPDBJ3T>6FMuQ2X0_K@buL`o=I%`$WA*+8jT96 zH;~&p2eiSCj5%ONd*laM;qe0bOmG-j+|khs$oB1P2)$CG<(wwYH!y)1*Yb9t_ri|B zt+FqR{23FVJG^{Vs+b855K;2x7s?qf@SUa~9ehs7@zEKK^&SoPAc+0Xi^W2*)@b`| zZ6Ttj_{{13bo;=49#=A`i@e7Hj{vEs?z@tdR)YCrJTC9*ES{c%o)fl;yU{k^UrPcS zY{jq-F|(J+QNk30cvJJGniOnNi?OI#Fb^Z>8-AqkApPZ)-V@h}eS5!+<*QSBANH!v zN0KgJ88n#7CtPK z0jpC=>Yua}Pa>eM7T7F5zqPz$M=>+hQEBDmiNzwqe4Cfrup!MQLd-ei6zAgMxPf67XTcTY%Xw%g!Qy9%^$2rVx4LBIjZAFEfo~zRvH5hvXeN=kn&zL`HX6 zh}qlkI4CG{+R7(Tc~{V_TpV;Tr)9MmrDZtN#Kw0mDqIT1GUsMT=&?9)i-q8H>xI>eqjklCRhx_D z4e(px4gqbiY~SMrDSWcR9k@8Q!11Zkhld!mT|ZzFs5J#+!W>vomVR@^Vo$Eg=<~a@ z{S;<$X(#m?Wu2U9YSaiUcJj*Rv%N;Cv{|1*&Lp~or{xQuuDZ#?u!95N?A@}ozntpn z&0=*PFEkc?dQ01=dt*?MNP35GMk{7mn+TALb$z<2+}mw%!!HjkN-Yo_vztnir{#Yj zrwuSgSy`)a2R-hl8)bf(Zj%PgIW5G>5vF3MA~lQ1oSV8bkmWQoHZ|m>SiQ}q{MSB1 z6rs<5ZXelf5IqQnenpChJGpoIEn#ga_B)IaG+zU@<9>%bI*Pez>rq%KdE+{q{JO^A zkFdf`H#Y*_gRE&06av{nx*wZn@}Z#`i260NUxwh$8_>iNPQ-{AIyaBTu1Kn(>qQF9{CR+{CEE z*1Gy#L1OwUy(>6gKEvF63-aP+F;>kV7aiRtrYF&0wBRb}5M5m&J|%=rrVNhuJ{lP4 z`pkUovL-v2O(>rRW}jknFUFsimzBXlt>Yfq=S5u1EEz_Ay~~d;zwmu9SMc$tVK&vv z9h+a`aJ6Dl3cc~E!pR;Uk=OAdW^30JA)?#ri7HV{dLShRtL4MwSjjkzoZp=)3VsmF_|U`kWjDA8)xRKPKu^u}0Uynm^l z+xaR=RU#qAbpf>{^LA=iPI^OA!3m~VKV(|c+EgK%pjiEZW_rTv-O(y?et4>@<`37< zfS!Fz;yz1;e2GJ`k*4-!+;&1{!}DzAcmfQJD7Wj`S8~Lpcikx;$;A%qNA+k0OaWiS z&OTcJDf)pGnjve3%m0Xw(^?$ud=m#ZZg-zXKiZ!Oud<22hp#06r-(}46P?y3g*!Y^ zaqcSaguNrnnl{3SHC$BwR86Y`cNZEtM9YQ&99zN;NXyTSu z8#+kj1t@t7)A(8}U3P(q#CR8=8{T$q=Nw`lij(4e+~J3eNoB-C?aK9L5b2qj02@m@ zRM{j}GAq$@0TONZ09%C2f)iGFRbL2{-8&OZMayVc-RFr;>noa}^v@DJw zm`G`61oaPVqu&4i9^c4ol~kEwjL@em=$a8aKYQKq0V!vm8eawXJ56uv%gqHGJ@8MA zC1R)8m(%7c+O|HQ>u31m(`*OaCfbW36udRn!o8s@!69}C+LgSAuBfGX^Tu_2Lu0GSz>XYM+nZ$sfugOli#iAF7+(}LHXGADUcoylskwGGYZlp4v*Ur$k$O#j3- zG1`&?zVvm>E?ZbsC2y2hoGH7-NWxK=YeMW#r3sw%Hn}qNqTK5nlbR?@deGo7tdO;U ztiG03ENZg!BYDs*cXfT9W>U6TM0a`*W=0`cO$@_E=Hsn!{G9ZBlp)<>#oXhZr7<+A zAM7(mSx3hcSc!3Y+03Z})-7pRR6PRFP9&e+bV3w7zkSn}G2?hz;1prJMteBDkRoP( ztf<}f8_#qi6Weh!ff#E0tamDq{=^{`q(CE9w|1Zj;(!+#IWgjtnqBaAQO!>6Iw)2Q zL*c?!-~76- zVWx2@qig?I5G3_=(dAN_x63N#A0O({pDx{diSKwH8h82JSu!a5dx=bJaMIx%FQ?Ed z=KRu-5%xwjn>>o(h;@-!$$Mdb$pPt1_fJuV!42t*3NTQr)taAs=2u| z+Yqj(EUxEQ-sW<@#oMwM(nBtYpT$$Euk7-0&JsHs>?5ZnMsrl06^Wi(zHP8;Tj^DF z5baI05@I1dh&w8|F;=edCUX|~ME?yqpAPsMf5HkuUkZVvbEi(aGHXw7nVlFS-ZmFe z+9}d!mN8bK|NI;_T(E1_#(#Pa2RZ%9C#N3k8_UXH!s*Z4$#6o=-giCHYIn^U2i!P>Ax$3;kP*9b0}= z{}H7pd#fvXA+``Khr)T0554~tf_Wd$rmgIl`wOKgd_1`)u z|NrIw|Nd5d20t4Tb8|5MrIzYod(uf+gf%D-XA z{{SGEzcy89Ub1&Mi*S_qS(8q{H%d5><+L92QFuwvxQ?`6uRwYBM=6Lv|BM6Z4W`!rU`P zUb|#_)km}jlex-@QOu>`5zz*37Kmx46t4CvP-YAW3=F&vj)YAQ7-c}Z>)y$ zSMnz1qGO?My>+7ywv+UXhm3&WZ(`z?OKWqTnEkOW(`SbuuQAH7Cp(gq@V>vEw>4ET zN8|G>Pvk`K3t;)}_{wMSjm#=qJHVdDg2Pz-gfJ}Eh_M2r^` z#b*Rx%xH5vyl#)e9qye{$JcyNZ`Gq!Nz8n*%*<>OZggVo5A}OUoDYbRcyyX*&G}l7 zC%55eMWON3gpDqYpm2ZFuf(q4N(H68SV}8NbLcv2ywiEqRLK5CECZPwUI#Z=%C5sE(SF zYQ8Xg^4CeCpb|`q4gpz>G*fFYBt8vmH4bk2$$PT~?S#?5AMOK1zU?TDFebj8K-gCD5VfcO~4&16qd#_cnnsf@vZA3Xsq*j;w9K} zNZs8E;%_0}n!Dik&?r_fUnb1xg6agv>Dk(n<{tN>T+mD7x)@&U`u?)k97-!Mp;l>Y zI9Fg~X!sFZ0wI!eRXw<$TIg-*QP*Go%B#TkNEJgGZ_)8YNve8zNy9Uqm%$OsTRXOJ zbz-_{))X=3@qlI`bzc3(=3jMXlzO<^Q!2pPq={uFXLE<5RT$+}M#fTsnV+(qzf8L= z=0asPV!-(r!8PRr+O_<~USliQs_N9kYs_EjicW3oT6Mg?4%w5QQLT~wJGGZ zyl@M?H~37O^^k9A_kUA8YxX3T$A}PC6qjtWE8zm^-|7QvMY~jgT{Bc*l!uyG5(83SvM8`KyF^DCD-C@@4(o3! zMPzy}r{N6@+ZE(6IfcK2#dD{SPTTa2S9|)MWj3t{M|=8hqK8TtL&0Q;AKULH?90Qc zCJ-_BT0xnrbk0_r^a@%qd(o7U-iE_$lJLezyD#3=sPzVWL{$S4sxdGV>M#2G>-{`( zN)8N4ZxA=a|GH!^oQQjP9mU}1u1#&+I=En7bE>%5y@x@N?GbTqSM5LE_@UU^3h zVAmB(4qY1kL}FRfcx(d)Ea-UkDc=+FtY{@&EAvX;^ZP8v+ZJ%}qCtg~#`B`mPkm~v zsHljU-}KyhD||?-islul#8^#FwV_Fy!>toezSy;!q z%G@duAsAPUDoZrBDBK||;^1+PF}S>ciVT^jCyzb(sVAC}!BX?|W*XOL!MgIky6R4X zpv?%#WCub7chkLI5gd5j$XkIA$m2gOxEP8OHJ_wEPP0UTM_ps#)k5ASwF0Q;8m8?s z7pj396XX~tu7WP)v91h_&zw4i>)kpC3R97I&nuO5ih)8s3x=B;AeL$2)*onw_<2+zZ>UcmUjODw;OlN!rNCPMT}{R zJE@+axc>I9KM79rT+dj-|*;KybxAe&U+Ge{r_IVNSnFcD zjFM0C*oNW*USa{V4r+=_)Pi1%<(J0~;?9@?ssS|6ZhQj>!WwzVtZt5N#@2nr>duPN z?7UR9)`r{Ziz-r+_Zl?c700c$afPfI-~%JI1`a7)d2diQXpY(^8VFINuz0Bk3IGvi zbj6{hg}HcEAQVcGadlcjtIZ8TC?|e0@Krn=Y8q2K%Hz`k>!#OYyz&{|5b_FP3rjo} zVGrn|O)ovNok02&89!O%l-K-WdrCx3sr-kK&PrIGq6D#Lg`H(YoTR4BuNZ84o6Elv z;g1zZ;90Esu8Lm1$syB$$+WXFTYTyK2!q0#b0yC|EMCx8HDgQgg0sE54V+ zr_NI`#|e-&xLcW`rY6ceUC>I=Wf;8afYlc9xf700+2?X;}Br08{vyH>lC!RPY&;*Jw8KrRm z84k6w(}`R{T2$SMt7i8(8I$V1xBEU2^qEkXEz7wQuwm#mk5LZp49Xu-J`(c&@Lwg^dt*C?-X(virIEVum8GwqvLC^}8A6la6<<|a&QEm6}cc_T&^OPFE~9_1CZY>Rp#CxC|cpTL?p7<`q+{g8DzN8;vt90*e+-Z;H2139VDi97slg5VYN7^JWrEGRVM0FJ{w5 zT)%!@NIeYMg#u=fr7lboke=D(iz7({CL7MCyTDVzZ1G zeq^gTa{!60h%^$kGXL6Wp4{LG>#Tl3kC2|82=W-THOWS|Zv#QVrVYp_3dPx_%W|N8Opu^G z;1gqd{iB&~$$Dev)+<`2Mkaj``7)6_J;pRh=k0VI2V0#L!=1K&1Lyb`i`ER)#mxl0 z<@%M&B*zXx?j?41WG%cvk@64(9#1c~%&)mXh~~R1Io$0RlWmGzUCf1l^iUk=0(_!= zINo&1#cie5FVxJnJiD#b@e~6eXLx6&w2ctcXmP2*ys}o54`W&zVt!`e_~3!JLPPDq zRUlwd$OT4bq^P(Bb#Nd`<3qq}vq!tFcIJIHmnoU&XqOZEl+ol)HiJsPEdKry>Vt^! z7)*e;EE~A?Ipr6<`jWfF2c8Hu6UwyHr%%(p=U385LSuy>6~5&2UUBryIeNWqFYiv4 z<3-KdE$q67aIyFsVfFZK-o8a#nP)>bBM!N5{|`6#d#Q@@fjVV!r2p zk2}m*RM$S8XECBV{`&Pv`I=+Te-%QK)UG3k3>h*~l?mdU#tWSNTKWcFZ);Mq3IAYA zdRcfJOf`8w@6d<=lAAlcZLexS!mnTP6O<&he>35TK=XM$u5K(f?=I@Ud-=8xGrp$A z#4-MkwMT9J*ohO}UrCJVH+!pTXlM){{+y6UeuH!om~vdryz7L>zp_tmiP7PxB6;n| z5*aNn$N{py#BxEreEjJeXoM94nPM7YG7{&MH%1j_^IAvk_gdysEoN|T-MSSu(a8_< z#4B|B4d^#4O)E`1QX#uJcGb>KE91V|Q_-&!4F;*N@>u+|WV3jQTz@#1fBGsNS(<*E z*hJqc52RMS^1&m8p?^StIZuPW50`^hvd~X=LNa35|51|Ru$PvQvJA2G_I>N+>((22 zAiNt#;A2kS8aF;dD)|wjeEy)bS^9KX(b!H()os+>Tsp3#ZGv!?HBGdic#XQrF|5)? z{kTg{<&I`)U1BkI%KqzLiPrIrv02mnTUDFes4ua!eBUrr{8FX^XdWOU*0;#Er5pqq zh#cfye8W`154K)w8xa~heC$}iQ{VLCP|gYC&>=(KV9D*l%S#M7@uXfzoDg-*k$Hu7 zx%E+*2N(qD;BbgTfDYcl@)_;xHqvR$jj5j5mc?;YJ8Rp1AbblSGyRgt175~5hxxXc zu*(ZlxKra%cT+(ZuRk>>Zx#z6E7Zg$NXJHH`r79=QHl#LM{5e!doDy$_Fgg}z`8&i zSat`w4&^=du|`lmV+xNHkGtK3O5eR;^YxONM_9w^VO~u@<+5cXoqo< zYB@J~2j?KXft*qk0B*iRs-pHbJI)eY{}tcrtFnYCB|i=2aFJi9M{TQpD|zlFcu~-{ z4!qcyreoaE=2U~chHpg3wRny`(dI?W@sM^0@V%tx(jv^1BUmU9M~QfW4`wz){)g-2JH;|D&peiK0aZl*NU8% zi~q^{`=YkY-#;o&)DB&ZpQXOm{p=(D9^OTX!GD zg7L2s!m|*qF#bw8yO0&eThv4&=5h@!J;X@jty^xyD&f!-Z#|4>BK7hzCMq|-8btSf z5x@4x2LA!D@;e_WccUW*Q6}osI{lmDSn&4JNO=oUd4HB%vqs85Ob$8@M>ll!k_?nB5B6 zXEq^(m9P2&2*Eq2gb^Q_?Vi<5h%%{yYagqnwg13@DlLzZYU=8UzRXbUK4`(^RVzuf zzl(Mxlgy0#{GE_FuW!eT(ty-dbNdA`-U<-FCT~}!$ws(z`J{TsXrN6;rE!XnS*YF$ zvoC%kWxQ$~6e31@=wPXWMvY|(kavatgPUJlSdK}0UR5nJ!fX9RXRS+;2dtKJfd6~! z()EHw2Ra&XVl4xap0mhlCLfK{D#1Bn!_jpU0)^2TI82t|yS-Ds|H1e2R+-Zgd2bBo zgiDfDOr8TTry;Yhrest~GX!(xB%2|Qprmi#dg(G){aH{eydbQhRZJbzze?nZpcvkN!nV%x z^1XE-^j2W?rg>NLq;qDO53~!kn&;ftZ=-vi>OM6;g$d)Ci@MX$+&pZY)eT2m7;WdV z<{S*f#uX+ecuFhh)1U0d^M+LX_1CtHXUXTK;Zs%|C!}Jcul*Ls|x>*jt7FOforn`^kES_4;)sZBZ z{Pide4WFAe#EOi)tY|gh7xB9rIwIkXbdJRLPm6TPnjpsZ7bQ2L#h43CcBZ@qgvvTVu zuyaWh-&Ivjf%~C^R|1WN$dcSqW8_G89I1Z&%b{`}gZ&B}P*cW@%lYGvGF%?e#*R@ugi{b*1siCQyt~Ph!|{ll-tm|i2X2Yc zpwB;us3UcD5=T?in8c-DKNPugX^)+!9r`drJ~q;%iX72{&sNd{U-(E>5$T>}X7<(< z*{y+07F8{FGycB*K0bPL<}5xnZeB#LXgB9#~Jd>+|lmvtfS>8de|8??Yl;r&m-REPcGb%uvz|8s3TUY4N5%v1y(-k=l z1tP0{J7fI&2_!+Z z#;aRT_4I7T?quD#Q@HC;4P!+3TJk9-ZZJM3*|jeEeaVGc(l@lT4$r=re`Vc?cy>k%%C(-Pgn7K_QMX6lme zKFFt$!-l;jMDk-$9lhk+Mlx+deJl;QhZKG%J;CmW7Zvw3J6pJ7hBaGl(_X<6UQxNa zzM)}y+>xlSU$^zuiL$(r<^1+A)*loltzE0fpGezAn+<0tS-@UzZ~Bli`74N4X0~My zUED4+^mjT@HqHwRy}Z=b(D2W@>Bjj>{EE^NYp#|WUt~pD&5OB2p^up{0>UM1-0*y3 zGA)q?8`Su&&urOaJ)5f>n~&24QgrYZ93YOMGD--e{~X@NZ^y@dKB^+8lJYW`x{Oh- zD_0(}jY>*NVwPjqUcH9tF#g8cFAVQXhuS9wZ?J@2?bVa9+1Ph5TO2fx*&nydPLwoe> znQuGGz!8^9rCldQ@1&}#HDkSKD&AycO6YVjT9guA8<)O0{(kuj3bZ%<_7ttDRa=3B3l0#(apO+!bM*%WnOyyNWtLwRph- zpEiD$I-%JWJjOr0+Ehi*yT>A}sa6CVCet!UholGfK=LyPfpFXQDkYcIdpO|@bz`n! zvHzLMsxJLD2l6P!M@!4f$V{SzT4Ln*TVUYwk-cL=lRbaOxc5c(^O`y_L;6NdztB&V z2Pv7LSe1`HeHSYJSEjE`+T8ikW4?a=+(}l}xC76vm>1*d z_!(4##2?W2c#Rmn2BF%FnaP9u_tQ6O1fGzs(OY=J#ajW(1nxE6p7Oar$CmKvXRel@ z%Kq3@yGy4rKhsC;O|4?=k{`~0Tj>4J-NysfNsu-(&^5-%Gw^3Md$%s(6DPE8oRISQ zv*V}1_kW__&0Jbq`b3%!*>|%k@meb}?#v^$4Tey3x5xnxS47SzCJQyUnq= zpDe)vo6CK!(F`=PH z156J+S`#04V`ViH9;b1_B!0zAFh1W6+k-@(e9=6NOrP}p%*m7Q%F1jf1mf0C!&Uyv zHvM)#wewjwpA^WxChXoAeSLj{qTx#344&bZg*l8{7Yy2~Efk#t0WZ#NOl)!gTqmgi z%I1jh@XAS!M#D8Vd+Fl7o$zwJcpy@WHXN7+1_>+gcCbCQY}2ObjV^F}YB!_qpY2iL zm+Tds0Dl4I>8SJ#Oi=Y~WYCH|U9s|oMvtpLcj5$=wriC1;4GV)nl?0IoUvSk4&1Bz zq^qP=Cih&=o96I#jPtFw6Sey>Kkq3rXWn0#vxD=pfKz+BJ%XHf1*KiJ1_B-ujWEwI zqlxg%3Vmuv9et?xTmnaxbanXl-0i6k)?PM4BrzoKYrfy3m}6S)sz4ad)@d>$MvjDo zZ4BJlNReuK^H-yrPrBTEOx9SCyhC>%_1Fxx3Lf+lMW)i0%{;WuB0lZOlU|Qf#cuIZ zJ!%yrv@K=t)z~t9`vx!!cXXd(w=W9|zgBh}>7`<<)oy;o#_;}8tYZ#&3m!kA4_|zs z*ziJ8H+Mt+2F!s5qmhlg#;zo}nT=mZ61sG$viA;_#Kstdh}=E9cdHB?N-bkwYd;$? z=9twOw<0DlW3@pgK#=ZX05=B}cnjh*aeIvi50A6P2F z-^2}gnFN7^W58;~viI*TGkhkdWo9l8OBc?Lg)t(UQ^$E`GaAjl z`s_D7vNMoud^r?~$in0Yp7!^zV+^rI@$`dIQZhq@7NF|DsAL7*9j>m0&ups-5+bHf za`|eJUM-aE+s>#^$aR7JYK z*aldyC%YtQ06>_j0{Is3c+Jf`8^0{~6vIn3R zQP-O{Z-knfmIdmxN|dR-hBKz8J+*yM^q&^frz%w13++`f4dfG;BDJ zMu-d@boX%l_BHY5e8%_hV$gR$LEfd(v6o$|AtY$Tq>OGi#d<+V_91)_@YcRRV4|;{ z1C@f@l9!3{W^Zb9Lhmmy3dv@dKcni_|^op8X!W2f!gO+D1*?RQ?YJ?FAJ z#qqSFC)TnW-XFhyb%WBZnm$+rx*5who8eZqks}{xWQ+}10KbOm#e(k5do_AxuuParf&&|{_|)qjU2G-TU0QymvH|!TTDJA8xpML^$A2@K}k-hP8 ztB&K_o4ua72N$t-m6aBjmTts-zhq^}&w8En6S-~g-Zv8y(>r~roEPJ*=%--dsB|H1 zb$Dp#=svQFhl0@{2oP0KCBb)6JJS2g?s@-yLqu)??wR~9kH1!9pifRWn~s698Fu6R ze0}@McECsyZVC%3nEO!sn%O_TPxaIvt42V3k}BYVG;*X91so?QCue7ql`bmAJ>eKN zdL1};nN4wU_RsqIyPMW)1@Le3H(a%d(v_)~*mg-tIjR0U0_2ljam6AE+|8XLOKYln z_ip$_^`1|Z9qlSQf7jvfx-FWFoz#>mfeVHOuxNWJdFR@z{219GWrw%dke@ZogNt6Z zQOR9e_(FZ&3i7(6ZPR}~v#6Yp@{YBL(n{?%Av$d;#x66h|&V2q|cvl-y zuOcqw{>>TEzP+T7NN42VoFPLMe+=uu*5r_sWC==1h0pd9r7`~e`{Vv4&#qag$vbac U{Z{`p@lfaKFVc(FwfXgb0e=aTMgRZ+ diff --git a/src/pymmcore_widgets/_models/_q_device_prop_model.py b/src/pymmcore_widgets/_models/_q_device_prop_model.py index cc6782520..f0ddbc2c2 100644 --- a/src/pymmcore_widgets/_models/_q_device_prop_model.py +++ b/src/pymmcore_widgets/_models/_q_device_prop_model.py @@ -1,12 +1,10 @@ from __future__ import annotations +from contextlib import suppress from copy import deepcopy from typing import TYPE_CHECKING, Any, cast -from qtpy.QtCore import ( - QModelIndex, - Qt, -) +from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt from qtpy.QtGui import QBrush, QFont, QIcon from superqt import QIconifyIcon @@ -145,87 +143,147 @@ def get_devices(self) -> list[Device]: return deepcopy([cast("Device", n.payload) for n in self._root.children]) -# class FlatPropertyModel(QAbstractProxyModel): -# """Presents every *leaf* of an arbitrary tree model as a top-level row.""" - -# def __init__(self, parent: QObject | None = None) -> None: -# super().__init__(parent) -# self._leaves: list[QPersistentModelIndex] = [] - -# def index(self, row: int, column: int, parent: QModelIndex = ...) -> QModelIndex: -# if not (sm := self.sourceModel()): -# return QModelIndex() -# return sm.index(row, column, parent) - -# # -------------------------------------------------------------------------------- -# # mandatory proxy plumbing -# # -------------------------------------------------------------------------------- -# def setSourceModel(self, source_model: QAbstractItemModel | None) -> None: -# super().setSourceModel(source_model) -# self._rebuild() -# # keep list in sync with structural changes -# source_model.rowsInserted.connect(self._rebuild) -# source_model.rowsRemoved.connect(self._rebuild) -# source_model.modelReset.connect(self._rebuild) - -# # map source ↔ proxy ----------------------------------------------------- -# def mapToSource(self, proxy_index: QModelIndex) -> QModelIndex: -# return ( -# QModelIndex(self._leaves[proxy_index.row()]) -# if proxy_index.isValid() -# else QModelIndex() -# ) - -# def mapFromSource(self, source_index: QModelIndex) -> QModelIndex: -# try: -# row = self._leaves.index(QPersistentModelIndex(source_index)) -# return self.createIndex(row, source_index.column()) -# except ValueError: -# return QModelIndex() - -# # shape ------------------------------------------------------------------ -# def rowCount(self, _parent: QModelIndex = NULL_INDEX) -> int: -# return len(self._leaves) - -# def columnCount(self, parent: QModelIndex = NULL_INDEX) -> int: -# if sm := self.sourceModel(): -# return sm.columnCount(self.mapToSource(parent)) -# return 0 - -# # data, flags, setData simply delegate to the source -------------------- -# def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) ->Any: -# if sm := self.sourceModel(): -# return sm.data(self.mapToSource(index), role) -# return None - -# def flags(self, index: QModelIndex) -> Qt.ItemFlag: -# if sm := self.sourceModel(): -# return sm.flags(self.mapToSource(index)) -# return Qt.ItemFlag.NoItemFlags - -# def setData( -# self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole -# ) -> bool: -# if sm := self.sourceModel(): -# return sm.setData(self.mapToSource(index), value, role) -# return False - -# # helpers ---------------------------------------------------------------- -# def _rebuild(self) -> None: -# """Cache every leaf `QModelIndex` of the tree.""" -# if not (sm := self.sourceModel()): -# return -# self.beginResetModel() -# self._leaves.clear() - -# def walk(parent: QModelIndex) -> None: -# rows = sm.rowCount(parent) -# for r in range(rows): -# idx = sm.index(r, 0, parent) -# if sm.rowCount(idx): # branch -# walk(idx) -# else: # leaf -# self._leaves.append(QPersistentModelIndex(idx)) - -# walk(QModelIndex()) -# self.endResetModel() +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 = 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) + + self._rebuild_rows() + + 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 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() or not self._source_model: + return None + + row = index.row() + if row >= len(self._rows): + return None + + drow, prow = self._rows[row] + col = index.column() + + if col == 0: # Device column + device_idx = self._source_model.index(drow, 0) + return self._source_model.data(device_idx, role) + elif col == 1: # Property column + device_idx = self._source_model.index(drow, 0) + if device_idx.isValid(): + prop_idx = self._source_model.index(prow, 0, device_idx) + return self._source_model.data(prop_idx, role) + + return None + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + if not index.isValid() or not self._source_model: + return Qt.ItemFlag.NoItemFlags + + row = index.row() + if row >= len(self._rows): + return Qt.ItemFlag.NoItemFlags + + drow, prow = self._rows[row] + col = index.column() + + if col == 0: # Device column + device_idx = self._source_model.index(drow, 0) + return self._source_model.flags(device_idx) + elif col == 1: # Property column + device_idx = self._source_model.index(drow, 0) + if device_idx.isValid(): + prop_idx = self._source_model.index(prow, 0, device_idx) + return self._source_model.flags(prop_idx) + + return Qt.ItemFlag.NoItemFlags + + 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() diff --git a/src/pymmcore_widgets/_models/_tree_flattening.py b/src/pymmcore_widgets/_models/_tree_flattening.py index 5aa017cf6..3e356c106 100644 --- a/src/pymmcore_widgets/_models/_tree_flattening.py +++ b/src/pymmcore_widgets/_models/_tree_flattening.py @@ -5,14 +5,24 @@ from qtpy.QtCore import ( QAbstractItemModel, - QIdentityProxyModel, QModelIndex, QObject, + QSortFilterProxyModel, Qt, ) -class TreeFlatteningProxy(QIdentityProxyModel): +class _Token: + """Unique, stable pointer stored in each QModelIndex.""" + + __slots__ = ("is_child", "top_row") + + def __init__(self, top_row: int, is_child: bool = False) -> None: + self.top_row = top_row + self.is_child = is_child + + +class TreeFlatteningProxy(QSortFilterProxyModel): """A proxy model that flattens a tree model into a table view with expandable rows. This model allows you to specify a row depth, which determines how many rows are @@ -22,7 +32,7 @@ class TreeFlatteningProxy(QIdentityProxyModel): expand and collapse child rows based on the specified depth. """ - def __init__(self, row_depth: int = 0, parent: QObject | None = None) -> None: + def __init__(self, parent: QObject | None = None, row_depth: int = 0) -> None: super().__init__(parent) # the row depth determines how many rows we show in the flattened view # for example, if row_depth=0, we revert to the original model, @@ -37,6 +47,10 @@ def __init__(self, row_depth: int = 0, parent: QObject | None = None) -> None: # one entry per proxy row self._row_paths: list[list[tuple[int, int]]] = [] + # stable pointers reused by createIndex + self._top_tokens: list[_Token] = [] + self._child_tokens: dict[tuple[int, int], _Token] = {} + def set_row_depth(self, row_depth: int) -> None: """Set the row depth for the flattened view.""" self._row_depth = row_depth @@ -58,27 +72,27 @@ def headerData( return f"Level {section}" # Level 0 = root, Level N = leaf return super().headerData(section, orientation, role) - def rowCount(self, parent: QModelIndex | None = None) -> int: - """Return the number of rows under the given parent.""" - if parent is None: - parent = QModelIndex() - if self._row_depth <= 0: - return int(super().rowCount(parent)) + # def rowCount(self, parent: QModelIndex | None = None) -> int: + # """Return the number of rows under *parent* in the flattened view.""" + # if parent is None: + # parent = QModelIndex() + # if self._row_depth <= 0: + # return int(super().rowCount(parent)) - if not parent.isValid(): - # Top-level: return number of primary flattened rows - return len(self._row_paths) + # if not parent.isValid(): + # # root in proxy - every top-level row represents one path we stored + # return len(self._row_paths) - # For a parent row, return number of children if expandable - if parent.internalId() == 0: # Top-level row - parent_row = parent.row() - if parent_row in self._expandable_rows: - return len(self._expandable_rows[parent_row]) - return 0 + # # Top-level rows have an **even** internalId; they may expose children. + # if (parent.internalId() & 1) == 0: + # children = self._expandable_rows.get(parent.row()) + # return len(children) if children is not None else 0 + + # # Child rows (odd internalId) never have further children in this proxy + # return 0 def hasChildren(self, parent: QModelIndex | None = None) -> bool: - """Return whether the given index has children.""" - return bool(self.rowCount(parent)) + return self.rowCount(parent) > 0 def columnCount(self, parent: QModelIndex | None = None) -> int: """Return the number of columns for the given parent.""" @@ -92,74 +106,115 @@ def columnCount(self, parent: QModelIndex | None = None) -> int: return self._row_depth + 1 def setSourceModel(self, source_model: QAbstractItemModel | None) -> None: - """Set the source model and rebuild the flattened view.""" - super().setSourceModel(source_model) - if source_model: - source_model.dataChanged.connect(self._rebuild) - self._rebuild() + """Set the source model and rebuild whenever the source structure changes.""" + # Disconnect from the old model (if any) + old_model = self.sourceModel() + if old_model is not None: + try: + old_model.dataChanged.disconnect(self._rebuild) + old_model.rowsInserted.disconnect(self._rebuild) + old_model.rowsRemoved.disconnect(self._rebuild) + old_model.columnsInserted.disconnect(self._rebuild) + old_model.columnsRemoved.disconnect(self._rebuild) + old_model.layoutChanged.disconnect(self._rebuild) + old_model.modelReset.disconnect(self._rebuild) + except (TypeError, RuntimeError): + # Signals may already be disconnected or the model deleted + pass - def index( - self, - row: int, - column: int, - parent: QModelIndex | None = None, - ) -> QModelIndex: - """Returns the index of the item specified by (row, column, parent).""" - if parent is None: - parent = QModelIndex() - if self._row_depth <= 0: - return super().index(row, column, parent) - - if not parent.isValid(): - # Top-level rows (the primary flattened rows) - if row < 0 or row >= len(self._row_paths): - return QModelIndex() - if column < 0 or column >= self.columnCount(): - return QModelIndex() - return self.createIndex(row, column, 0) # 0 indicates top-level - else: - # Child rows (expanded children of a parent row) - parent_row = parent.row() - if parent_row in self._expandable_rows: - children = self._expandable_rows[parent_row] - if row < 0 or row >= len(children): - return QModelIndex() - # For child rows, check against the child path length, not _max_depth - child_path = children[row] - max_child_col = len(child_path) - 1 - if column < 0 or column > max_child_col: - return QModelIndex() - # Add bounds checking for parent_row to prevent overflow - if parent_row < 0 or parent_row >= len(self._row_paths): - return QModelIndex() - # Use parent_row + 1 as internal pointer to identify this is a child - return self.createIndex(row, column, parent_row + 1) - return QModelIndex() - - def parent(self, index: QModelIndex) -> QModelIndex: - """Returns the parent of the given child index.""" - if self._row_depth <= 0: - return super().parent(index) + super().setSourceModel(source_model) - if not index.isValid(): - return QModelIndex() + # Connect to the new model (if provided) + if source_model is not None: + # All of these signals indicate that the structure of the source + # model has changed in a way that could invalidate our cached + # row/column paths - so trigger a rebuild. + for sig in ( + source_model.dataChanged, + source_model.rowsInserted, + source_model.rowsRemoved, + source_model.columnsInserted, + source_model.columnsRemoved, + source_model.layoutChanged, + source_model.modelReset, + ): + # We ignore all positional arguments from the signal. + sig.connect(lambda *_, **__: self._rebuild()) + + # Build initial flattened representation + self._rebuild() - internal_ptr = index.internalId() - if internal_ptr == 0: - # This is a top-level row, so no parent - return QModelIndex() - else: - # This is a child row, parent is at internal_ptr - 1 - parent_row = internal_ptr - 1 - # Add bounds checking to prevent overflow - if parent_row < 0 or parent_row >= len(self._row_paths): - # Invalid parent row, return invalid index - return QModelIndex() - # Return parent at column 0 for tree structure - return self.createIndex(parent_row, 0, 0) + # ------------------------------------------------------------------ + # Index <-> Parent helpers + # + # * top-level internalId = row << 1 (always even) + # * child rows internalId = (row << 1)|1 (always odd) + # ------------------------------------------------------------------ + + # def index( + # self, + # row: int, + # column: int, + # parent: QModelIndex | None = None, + # ) -> QModelIndex: + # """Return proxy QModelIndex for (row, column, parent).""" + # if parent is None: + # parent = QModelIndex() + # if self._row_depth <= 0: + # return super().index(row, column, parent) + + # # ---------------- root → top-level rows ---------------- + # if not parent.isValid(): + # if ( + # row < 0 + # or row >= len(self._row_paths) + # or column < 0 + # or column >= self.columnCount() + # ): + # return QModelIndex() + # return self.createIndex(row, column, row << 1) # even id + + # # ---------------- top-level → child rows --------------- + # if (parent.internalId() & 1) == 0: # only top-level may have children + # top_row = parent.row() + # children = self._expandable_rows.get(top_row) + # if children is None: + # return QModelIndex() + # if ( + # row < 0 + # or row >= len(children) + # or column < 0 + # or column >= self.columnCount() + # ): + # return QModelIndex() + # return self.createIndex(row, column, (top_row << 1) | 1) # odd id + + # # children have no further descendants + # return QModelIndex() + + # def parent(self, index: QModelIndex) -> QModelIndex: + # """Return the parent of *index* in the flattened hierarchy.""" + # if self._row_depth <= 0: + # return super().parent(index) + + # if not index.isValid(): + # return QModelIndex() + + # internal_id = index.internalId() + + # # child-less top-level rows + # if (internal_id & 1) == 0: + # return QModelIndex() # root + + # # child → parent is encoded in high bits + # parent_row = internal_id >> 1 + # if 0 <= parent_row < len(self._row_paths): + # return self.createIndex(parent_row, 0, parent_row << 1) + + # return QModelIndex() def mapToSource(self, proxy_index: QModelIndex) -> QModelIndex: - """Map from the flattened view back to the source model.""" + """Translate a proxy index back to the source model.""" if self._row_depth <= 0 or not (src_model := self.sourceModel()): return super().mapToSource(proxy_index) @@ -167,14 +222,16 @@ def mapToSource(self, proxy_index: QModelIndex) -> QModelIndex: return QModelIndex() row, col = proxy_index.row(), proxy_index.column() - internal_ptr = proxy_index.internalId() - if internal_ptr == 0: - # Top-level row: navigate to the column depth requested + tok = proxy_index.internalPointer() + is_child = isinstance(tok, _Token) and tok.is_child + + if not is_child: + # ---- top-level proxy row ---- if row >= len(self._row_paths): return QModelIndex() - path = self._row_paths[row] + path = self._row_paths[row] if col >= len(path): # beyond recorded depth return QModelIndex() @@ -182,80 +239,66 @@ def mapToSource(self, proxy_index: QModelIndex) -> QModelIndex: for r, c in path[: col + 1]: src = src_model.index(r, c, src) return src - else: - # Child row: these are the deeper children (C-level nodes) - parent_row = internal_ptr - 1 - if parent_row not in self._expandable_rows: - return QModelIndex() - children = self._expandable_rows[parent_row] - if row >= len(children): - return QModelIndex() - path = children[row] - # For children, we have different behavior per column: - if col == 0: - # Column 0: Don't show anything for child rows (they should be indented) - return QModelIndex() - elif col == self._row_depth: - # Target depth column: show the full child data (navigate the full path) - src = QModelIndex() - for r, c in path: - src = src_model.index(r, c, src) - return src - else: - # Other columns: no data - return QModelIndex() + # ---- child proxy row ---- + parent_row = tok.top_row + children = self._expandable_rows.get(parent_row) + if children is None or row >= len(children): + return QModelIndex() + + path = children[row] + if col == 0: + return QModelIndex() # intentionally empty - indentation column + if col != self._row_depth: + return QModelIndex() # other columns unused for child rows + + src = QModelIndex() + for r, c in path: + src = src_model.index(r, c, src) + return src def mapFromSource(self, source_index: QModelIndex) -> QModelIndex: - """Map from source model back to proxy model.""" - if self._row_depth <= 0 or not (self.sourceModel()): + """Translate a source index to the corresponding proxy index.""" + if self._row_depth <= 0 or not self.sourceModel(): return super().mapFromSource(source_index) if not source_index.isValid(): return QModelIndex() - # Build the path from root to this source index - path = [] - current = source_index - while current.isValid(): - path.append((current.row(), current.column())) - current = current.parent() - path.reverse() # Now path is from root to source_index - - # Check if this path matches any of our top-level rows + # Build path root→…→source_index + path: list[tuple[int, int]] = [] + cur = source_index + while cur.isValid(): + path.append((cur.row(), cur.column())) + cur = cur.parent() + path.reverse() + + # We flattened the tree by collecting *row_depth*-deep paths into + # `self._row_paths`. Each proxy row therefore has: + # + # column 0 → node at depth 0 (device) + # column 1 → node at depth 1 (property) when row_depth == 1 + # + # For a given *source* index we need to decide which proxy column + # represents it. for proxy_row, row_path in enumerate(self._row_paths): - # Check if the source path starts with our row path - if len(path) >= len(row_path): - matches = True - for i, (proxy_r, proxy_c) in enumerate(row_path): - if i >= len(path) or path[i] != (proxy_r, proxy_c): - matches = False - break - - if matches: - # This source index corresponds to our proxy row - if len(path) == len(row_path): - # Exact match - this is a top-level item - column = len(path) - 1 # Column based on depth - if column < self.columnCount(): - return self.createIndex(proxy_row, column, 0) - elif len(path) == len(row_path) + 1: - # This is a child of our top-level item - if proxy_row in self._expandable_rows: - children = self._expandable_rows[proxy_row] - child_row_in_source = path[-1][0] # Last element's row - if child_row_in_source < len(children): - column = len(path) - 1 # Column based on depth - if column < self.columnCount(): - return self.createIndex( - child_row_in_source, column, proxy_row + 1 - ) + if path == row_path: # exact match → deepest column + column = len(path) - 1 # row_depth + return self.createIndex(proxy_row, column, self._top_tokens[proxy_row]) + + # Is the source index the ancestor at depth-0 of this row? + # (e.g. device row when the proxy row represents a property) + if path == row_path[: len(path)]: + column = len(path) - 1 # 0 for device + return self.createIndex(proxy_row, column, self._top_tokens[proxy_row]) return QModelIndex() def _rebuild(self) -> None: self.beginResetModel() self._max_depth = 0 + self._top_tokens.clear() + self._child_tokens.clear() # one entry per proxy row self._row_paths = [] # mapping of depth -> (num_rows, num_columns) @@ -313,49 +356,129 @@ def _collect_model_shape( if depth < self._row_depth and model.hasChildren(child): self._collect_model_shape(model, child, depth + 1, pair_path) - def sort( - self, - column: int, - order: Qt.SortOrder = Qt.SortOrder.AscendingOrder, - ) -> None: - """Sort the proxy model by the specified column.""" + # def sort( + # self, + # column: int, + # order: Qt.SortOrder = Qt.SortOrder.AscendingOrder, + # ) -> None: + # """Sort the proxy model by the specified column.""" + # if self._row_depth <= 0: + # return super().sort(column, order) # type: ignore[no-any-return] + + # if column < 0 or column >= self.columnCount(): + # return # Invalid column + + # # Emit layoutAboutToBeChanged signal + # self.layoutAboutToBeChanged.emit() + + # # Sort our _row_paths based on the data in the specified column + # def get_sort_key(row_index: int) -> str: + # """Get the sort key for a row at the given index.""" + # proxy_idx = self.index(row_index, column) + # data = self.data(proxy_idx, Qt.ItemDataRole.DisplayRole) + # return str(data) if data is not None else "" + + # # Create list of (original_index, sort_key) pairs + # indexed_rows = [(i, get_sort_key(i)) for i in range(len(self._row_paths))] + + # # Sort by the sort key + # reverse_order = order == Qt.SortOrder.DescendingOrder + # indexed_rows.sort(key=lambda x: x[1], reverse=reverse_order) + + # # Reorder _row_paths and _expandable_rows based on sorted order + # old_row_paths = self._row_paths.copy() + # old_expandable_rows = self._expandable_rows.copy() + + # self._row_paths = [] + # self._expandable_rows = {} + + # for new_index, (old_index, _) in enumerate(indexed_rows): + # # Copy the row path + # self._row_paths.append(old_row_paths[old_index]) + + # # Copy expandable rows mapping with new index + # if old_index in old_expandable_rows: + # self._expandable_rows[new_index] = old_expandable_rows[old_index] + + # # Emit layoutChanged signal + # self.layoutChanged.emit() + # ------------------------------------------------------------------ + # Index / Parent helpers - we use _Token pointers, never integers + # ------------------------------------------------------------------ + + def index( + self, row: int, column: int, parent: QModelIndex | None = None + ) -> QModelIndex: + if parent is None: + parent = QModelIndex() if self._row_depth <= 0: - return super().sort(column, order) # type: ignore[no-any-return] + return super().index(row, column, parent) - if column < 0 or column >= self.columnCount(): - return # Invalid column + # ---------- root → top-level ---------- + if not parent.isValid(): + if not (0 <= row < len(self._row_paths)) or not ( + 0 <= column < self.columnCount() + ): + return QModelIndex() + # reuse token + while row >= len(self._top_tokens): + self._top_tokens.append(_Token(len(self._top_tokens))) + return self.createIndex(row, column, self._top_tokens[row]) + + # ---------- top-level → child ---------- + p_tok = parent.internalPointer() + if isinstance(p_tok, _Token) and not p_tok.is_child: + top_row = p_tok.top_row + children = self._expandable_rows.get(top_row) + if ( + children is None + or not (0 <= row < len(children)) + or not (0 <= column < self.columnCount()) + ): + return QModelIndex() + key = (top_row, row) + token = self._child_tokens.get(key) + if token is None: + token = _Token(top_row, True) + self._child_tokens[key] = token + return self.createIndex(row, column, token) - # Emit layoutAboutToBeChanged signal - self.layoutAboutToBeChanged.emit() + return QModelIndex() # a child has no grandchildren - # Sort our _row_paths based on the data in the specified column - def get_sort_key(row_index: int) -> str: - """Get the sort key for a row at the given index.""" - proxy_idx = self.index(row_index, column) - data = self.data(proxy_idx, Qt.ItemDataRole.DisplayRole) - return str(data) if data is not None else "" + def parent(self, index: QModelIndex) -> QModelIndex: + if self._row_depth <= 0: + return super().parent(index) + if not index.isValid(): + return QModelIndex() - # Create list of (original_index, sort_key) pairs - indexed_rows = [(i, get_sort_key(i)) for i in range(len(self._row_paths))] + tok = index.internalPointer() + if not isinstance(tok, _Token) or not tok.is_child: + return QModelIndex() # top-level rows → root - # Sort by the sort key - reverse_order = order == Qt.SortOrder.DescendingOrder - indexed_rows.sort(key=lambda x: x[1], reverse=reverse_order) + top_row = tok.top_row + if 0 <= top_row < len(self._top_tokens): + return self.createIndex(top_row, 0, self._top_tokens[top_row]) + return QModelIndex() - # Reorder _row_paths and _expandable_rows based on sorted order - old_row_paths = self._row_paths.copy() - old_expandable_rows = self._expandable_rows.copy() + def sibling(self, row: int, column: int, index: QModelIndex) -> QModelIndex: + """Return a sibling index for the given row and column.""" + if not index.isValid(): + return QModelIndex() - self._row_paths = [] - self._expandable_rows = {} + parent_index = self.parent(index) + return self.index(row, column, parent_index) - for new_index, (old_index, _) in enumerate(indexed_rows): - # Copy the row path - self._row_paths.append(old_row_paths[old_index]) + def rowCount(self, parent: QModelIndex | None = None) -> int: + if parent is None: + parent = QModelIndex() + if self._row_depth <= 0: + return int(super().rowCount(parent)) - # Copy expandable rows mapping with new index - if old_index in old_expandable_rows: - self._expandable_rows[new_index] = old_expandable_rows[old_index] + if not parent.isValid(): + return len(self._row_paths) - # Emit layoutChanged signal - self.layoutChanged.emit() + tok = parent.internalPointer() + if isinstance(tok, _Token) and not tok.is_child: + ch = self._expandable_rows.get(tok.top_row) + return len(ch) if ch else 0 + return 0 diff --git a/src/pymmcore_widgets/config_presets/__init__.py b/src/pymmcore_widgets/config_presets/__init__.py index fa0791e57..6e2dcf084 100644 --- a/src/pymmcore_widgets/config_presets/__init__.py +++ b/src/pymmcore_widgets/config_presets/__init__.py @@ -3,9 +3,9 @@ from ._group_preset_widget._group_preset_table_widget import GroupPresetTableWidget from ._objectives_pixel_configuration_widget import ObjectivesPixelConfigurationWidget from ._pixel_configuration_widget import PixelConfigurationWidget +from ._views._config_groups_editor import ConfigGroupsEditor from ._views._config_groups_tree import ConfigGroupsTree from ._views._config_presets_table import ConfigPresetsTable -from ._views._config_views import ConfigGroupsEditor from ._views._group_preset_selector import GroupPresetSelector __all__ = [ diff --git a/src/pymmcore_widgets/config_presets/_views/_config_views.py b/src/pymmcore_widgets/config_presets/_views/_config_views.py deleted file mode 100644 index 09d03c6d6..000000000 --- a/src/pymmcore_widgets/config_presets/_views/_config_views.py +++ /dev/null @@ -1,472 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, cast - -from pymmcore_plus import DeviceProperty, DeviceType, Keyword -from qtpy.QtCore import QModelIndex, QSize, Qt, Signal -from qtpy.QtWidgets import ( - QGroupBox, - QHBoxLayout, - QLabel, - QListView, - QSplitter, - QToolBar, - QVBoxLayout, - QWidget, -) -from superqt import QIconifyIcon - -from pymmcore_widgets._icons import DEVICE_TYPE_ICON -from pymmcore_widgets._models import ( - ConfigGroup, - ConfigPreset, - DevicePropertySetting, - QConfigGroupsModel, - get_config_groups, -) -from pymmcore_widgets.device_properties import DevicePropertyTable - -from ._config_presets_table import ConfigPresetsTable - -if TYPE_CHECKING: - from collections.abc import Iterable, Sequence - - from pymmcore_plus import CMMCorePlus - from PyQt6.QtGui import QAction, QActionGroup - - from pymmcore_widgets._models._base_tree_model import _Node - -else: - from qtpy.QtGui import QAction, QActionGroup - - -# ----------------------------------------------------------------------------- -# High-level editor widget -# ----------------------------------------------------------------------------- - - -class _NameList(QWidget): - """A group box that contains a toolbar and a QListView for cfg groups or presets.""" - - def __init__(self, title: str, parent: QWidget | None) -> None: - super().__init__(parent) - self._title = title - - # toolbar - self.list_view = QListView(self) - - self._toolbar = tb = QToolBar() - tb.setStyleSheet("QToolBar {background: none; border: none;}") - tb.setIconSize(QSize(18, 18)) - self._toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) - - self.add_action = QAction( - QIconifyIcon("mdi:plus-thick", color="gray"), - f"Add {title.rstrip('s')}", - self, - ) - tb.addAction(self.add_action) - tb.addSeparator() - tb.addAction( - QIconifyIcon("mdi:remove-bold", color="gray"), "Remove", self._remove - ) - tb.addAction( - QIconifyIcon("mdi:content-duplicate", color="gray"), "Duplicate", self._dupe - ) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - layout.addWidget(self._toolbar) - layout.addWidget(self.list_view) - - if isinstance(self, QGroupBox): - self.setTitle(title) - else: - label = QLabel(self._title, self) - font = label.font() - font.setBold(True) - label.setFont(font) - layout.insertWidget(0, label) - - def _is_groups(self) -> bool: - """Check if this box is for groups.""" - return bool(self._title == "Groups") - - def _remove(self) -> None: - self._model.remove(self.list_view.currentIndex()) - - @property - def _model(self) -> QConfigGroupsModel: - """Return the model used by this list view.""" - model = self.list_view.model() - if not isinstance(model, QConfigGroupsModel): - raise TypeError("Expected a QConfigGroupsModel instance.") - return model - - def _dupe(self) -> None: ... - - -class GroupsList(_NameList): - def __init__(self, parent: QWidget | None) -> None: - super().__init__("Groups", parent) - - def _dupe(self) -> None: - idx = self.list_view.currentIndex() - if idx.isValid(): - self.list_view.setCurrentIndex(self._model.duplicate_group(idx)) - - -class PresetsList(_NameList): - def __init__(self, parent: QWidget | None) -> None: - super().__init__("Presets", parent) - - # TODO: we need `_NameList.setCore()` in order to be able to "activate" a preset - self._toolbar.addAction( - QIconifyIcon("clarity:play-solid", color="gray"), - "Activate", - ) - - def _dupe(self) -> None: - idx = self.list_view.currentIndex() - if idx.isValid(): - self.list_view.setCurrentIndex(self._model.duplicate_preset(idx)) - - -class ConfigGroupsEditor(QWidget): - """Widget composed of two QListViews backed by a single tree model.""" - - configChanged = Signal() - - @classmethod - def create_from_core( - cls, core: CMMCorePlus, parent: QWidget | None = None - ) -> ConfigGroupsEditor: - """Create a ConfigGroupsEditor from a CMMCorePlus instance.""" - obj = cls(parent) - obj.update_from_core(core) - return obj - - def update_from_core( - self, - core: CMMCorePlus, - *, - update_configs: bool = True, - update_available: bool = True, - ) -> None: - """Update the editor's data from the core. - - Parameters - ---------- - core : CMMCorePlus - The core instance to pull configuration data from. - update_configs : bool - If True, update the entire list and states of config groups (i.e. make the - editor reflect the current state of config groups in the core). - update_available : bool - If True, update the available options in the property tables (for things - like "current device" comboboxes and other things that select from - available devices). - """ - if update_configs: - self.setData(get_config_groups(core)) - if update_available: - self._props._update_device_buttons(core) - # self._prop_tables.update_options_from_core(core) - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self._model = QConfigGroupsModel() - - # widgets -------------------------------------------------------------- - - group_box = GroupsList(self) - self._group_view = group_box.list_view - self._group_view.setModel(self._model) - group_box.add_action.triggered.connect(self._new_group) - - preset_box = PresetsList(self) - self._preset_view = preset_box.list_view - self._preset_view.setModel(self._model) - preset_box.add_action.triggered.connect(self._new_preset) - - self._props = _PropSettings(self) - self._props._presets_table.setModel(self._model) - - # layout ------------------------------------------------------------ - - left = QWidget() - lv = QVBoxLayout(left) - lv.setContentsMargins(12, 12, 4, 12) - lv.addWidget(group_box) - lv.addWidget(preset_box) - - lay = QHBoxLayout(self) - lay.setContentsMargins(0, 0, 0, 0) - lay.addWidget(left) - lay.addWidget(self._props, 1) - - # signals ------------------------------------------------------------ - - if sm := self._group_view.selectionModel(): - sm.currentChanged.connect(self._on_group_sel) - if sm := self._preset_view.selectionModel(): - sm.currentChanged.connect(self._on_preset_sel) - self._model.dataChanged.connect(self._on_model_data_changed) - self._props.valueChanged.connect(self._on_prop_table_changed) - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - def setCurrentGroup(self, group: str) -> None: - """Set the currently selected group in the editor.""" - idx = self._model.index_for_group(group) - if idx.isValid(): - self._group_view.setCurrentIndex(idx) - else: - self._group_view.clearSelection() - - def setCurrentPreset(self, group: str, preset: str) -> None: - """Set the currently selected preset in the editor.""" - self.setCurrentGroup(group) - group_index = self._model.index_for_group(group) - idx = self._model.index_for_preset(group_index, preset) - if idx.isValid(): - self._preset_view.setCurrentIndex(idx) - else: - self._preset_view.clearSelection() - - def setData(self, data: Iterable[ConfigGroup]) -> None: - """Set the configuration data to be displayed in the editor.""" - data = list(data) # ensure we can iterate multiple times - self._model.set_groups(data) - self._props.setValue([]) - # Auto-select first group - if self._model.rowCount(): - self._group_view.setCurrentIndex(self._model.index(0)) - else: - self._preset_view.setRootIndex(QModelIndex()) - self._preset_view.clearSelection() - self.configChanged.emit() - - def data(self) -> Sequence[ConfigGroup]: - """Return the current configuration data as a list of ConfigGroup.""" - return self._model.get_groups() - - # TODO: - # def selectionModel(self) -> QItemSelectionModel: ... - - # "new" actions ---------------------------------------------------------- - - def _new_group(self) -> None: - idx = self._model.add_group() - self._group_view.setCurrentIndex(idx) - - def _new_preset(self) -> None: - gidx = self._group_view.currentIndex() - if not gidx.isValid(): - return - pidx = self._model.add_preset(gidx) - self._preset_view.setCurrentIndex(pidx) - - # selection sync --------------------------------------------------------- - - def _on_group_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: - self._preset_view.setRootIndex(current) - self._props._presets_table.setGroup(current) - if current.isValid() and self._model.rowCount(current): - self._preset_view.setCurrentIndex(self._model.index(0, 0, current)) - else: - self._preset_view.clearSelection() - - def _on_preset_sel(self, current: QModelIndex, _prev: QModelIndex) -> None: - """Populate the DevicePropertyTable whenever the selected preset changes.""" - if not current.isValid(): - # clear table when nothing is selected - self._props.setValue([]) - return - node = cast("_Node", current.internalPointer()) - if not node.is_preset: - self._props.setValue([]) - return - preset = cast("ConfigPreset", node.payload) - self._props.setValue(preset.settings) - - # ------------------------------------------------------------------ - # Property-table sync - # ------------------------------------------------------------------ - - def _on_prop_table_changed(self) -> None: - """Write back edits from the table into the underlying ConfigPreset.""" - idx = self._preset_view.currentIndex() - if not idx.isValid(): - return - node = cast("_Node", idx.internalPointer()) - if not node.is_preset: - return - new_settings = self._props.value() - self._model.update_preset_settings(idx, new_settings) - self.configChanged.emit() - - def _on_model_data_changed( - self, - topLeft: QModelIndex, - bottomRight: QModelIndex, - _roles: list[int] | None = None, - ) -> None: - """Refresh DevicePropertyTable if a setting in the current preset was edited.""" - if not (preset := self._our_preset_changed_by_range(topLeft, bottomRight)): - return - - self._props.blockSignals(True) # avoid feedback loop - self._props.setValue(preset.settings) - self._props.blockSignals(False) - - def _our_preset_changed_by_range( - self, topLeft: QModelIndex, bottomRight: QModelIndex - ) -> ConfigPreset | None: - """Return our current preset if it was changed in the given range.""" - cur_preset = self._preset_view.currentIndex() - if ( - not cur_preset.isValid() - or not topLeft.isValid() - or topLeft.parent() != cur_preset.parent() - or topLeft.internalPointer().payload.name - != cur_preset.internalPointer().payload.name - ): - return None - - # pull updated settings from the model and push to the table - node = cast("_Node", self._preset_view.currentIndex().internalPointer()) - preset = cast("ConfigPreset", node.payload) - return preset - - -class _PropSettings(QSplitter): - """A wrapper for DevicePropertyTable for use in ConfigGroupsEditor.""" - - valueChanged = Signal() - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(Qt.Orientation.Vertical, parent) - # 2D table with presets as columns and device properties as rows - self._presets_table = ConfigPresetsTable(self) - - # regular property table for editing all device properties - self._prop_tables = DevicePropertyTable() - self._prop_tables.valueChanged.connect(self.valueChanged) - self._prop_tables.setRowsCheckable(True) - - # toolbar with device type buttons - self._action_group = QActionGroup(self) - self._action_group.setExclusive(False) - tb, self._action_group = self._create_device_buttons() - - bot = QWidget() - bl = QVBoxLayout(bot) - bl.setContentsMargins(0, 0, 0, 0) - bl.addWidget(tb) - bl.addWidget(self._prop_tables) - - self.addWidget(self._presets_table) - self.addWidget(bot) - - self._filter_properties() - - def value(self) -> list[DevicePropertySetting]: - """Return the current value of the property table.""" - return [ - DevicePropertySetting.model_validate(v) for v in self._prop_tables.value() - ] - - def setValue(self, value: list[DevicePropertySetting]) -> None: - """Set the value of the property table.""" - self._prop_tables.setValue([v.as_tuple() for v in value]) - - def _create_device_buttons(self) -> tuple[QToolBar, QActionGroup]: - tb = QToolBar() - tb.setMovable(False) - tb.setFloatable(False) - tb.setIconSize(QSize(18, 18)) - tb.setStyleSheet("QToolBar {background: none; border: none;}") - tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) - tb.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - - # clear action group - action_group = QActionGroup(self) - action_group.setExclusive(False) - - for dev_type, checked in { - DeviceType.CameraDevice: False, - DeviceType.ShutterDevice: True, - DeviceType.StateDevice: True, - DeviceType.StageDevice: False, - DeviceType.XYStageDevice: False, - DeviceType.SerialDevice: False, - DeviceType.GenericDevice: False, - DeviceType.AutoFocusDevice: False, - DeviceType.ImageProcessorDevice: False, - DeviceType.SignalIODevice: False, - DeviceType.MagnifierDevice: False, - DeviceType.SLMDevice: False, - DeviceType.HubDevice: False, - DeviceType.GalvoDevice: False, - DeviceType.CoreDevice: False, - }.items(): - icon = QIconifyIcon(DEVICE_TYPE_ICON[dev_type], color="gray") - if act := tb.addAction( - icon, - dev_type.name.replace("Device", ""), - self._filter_properties, - ): - act.setCheckable(True) - act.setChecked(checked) - act.setData(dev_type) - action_group.addAction(act) - - return tb, action_group - - def _filter_properties(self) -> None: - include_devices = { - action.data() - for action in self._action_group.actions() - if action.isChecked() - } - if not include_devices: - # If no devices are selected, show all properties - for row in range(self._prop_tables.rowCount()): - self._prop_tables.hideRow(row) - - else: - self._prop_tables.filterDevices( - include_pre_init=False, - include_read_only=False, - always_show_checked=True, - include_devices=include_devices, - predicate=_hide_state_state, - ) - - def _update_device_buttons(self, core: CMMCorePlus) -> None: - for action in self._action_group.actions(): - dev_type = cast("DeviceType", action.data()) - for dev in core.getLoadedDevicesOfType(dev_type): - writeable_props = ( - ( - not core.isPropertyPreInit(dev, prop) - and not core.isPropertyReadOnly(dev, prop) - ) - for prop in core.getDevicePropertyNames(dev) - ) - if any(writeable_props): - action.setVisible(True) - break - else: - action.setVisible(False) - - -def _hide_state_state(prop: DeviceProperty) -> bool | None: - """Hide the State property for StateDevice (it duplicates state label).""" - if prop.deviceType() == DeviceType.StateDevice and prop.name == Keyword.State: - return False - return None diff --git a/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py index 503a05f5d..14024ce88 100644 --- a/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py +++ b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, cast from pymmcore_plus import DeviceType -from qtpy.QtCore import QModelIndex, QSize, QSortFilterProxyModel, Qt, Signal +from qtpy.QtCore import QModelIndex, QSize, Signal from qtpy.QtWidgets import ( QLineEdit, QSizePolicy, @@ -15,7 +15,10 @@ from superqt import QIconifyIcon from pymmcore_widgets._icons import DEVICE_TYPE_ICON, PROPERTY_FLAG_ICON -from pymmcore_widgets._models import Device, DevicePropertySetting, QDevicePropertyModel +from pymmcore_widgets._models import Device, QDevicePropertyModel +from pymmcore_widgets._models._q_device_prop_model import DevicePropertyFlatProxy + +from ._device_type_filter_proxy import DeviceTypeFilter if TYPE_CHECKING: from collections.abc import Iterable @@ -24,86 +27,6 @@ # TODO: Allow GUI control of parameters -class DeviceTypeFilter(QSortFilterProxyModel): - def __init__(self, allowed: set[DeviceType], parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) - self.setRecursiveFilteringEnabled(True) - self.allowed = allowed # e.g. {"Camera", "Shutter"} - self.show_read_only = False - self.show_pre_init = False - - def _device_allowed_for_index(self, idx: QModelIndex) -> bool: - """Walk up to the closest Device ancestor and check its type.""" - while idx.isValid(): - data = idx.data(Qt.ItemDataRole.UserRole) - if isinstance(data, Device): - return DeviceType.Any in self.allowed or data.type in self.allowed - idx = idx.parent() # keep climbing - return True # no Device ancestor (root rows etc.) - - def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool: - if (sm := self.sourceModel()) is None: - return super().filterAcceptsRow(source_row, source_parent) # type: ignore [no-any-return] - - idx = sm.index(source_row, 0, source_parent) - - # 1. Bail out whole subtree when its Device type is disallowed - if not self._device_allowed_for_index(idx): - return False - - data = idx.data(Qt.ItemDataRole.UserRole) - - # 2. Per-property flags - if isinstance(data, DevicePropertySetting): - if data.is_read_only and not self.show_read_only: - return False - if data.is_pre_init and not self.show_pre_init: - return False - if data.is_advanced: - return False - - # 3. Text / regex filter (superclass logic) - text_match = super().filterAcceptsRow(source_row, source_parent) - - # 4. Special rule for Device rows: hide when it ends up child-less - if isinstance(data, Device): - # If the device name itself matches, keep it only if at least - # one child survives *after all rules above*. - if text_match: - for i in range(sm.rowCount(idx)): - if self.filterAcceptsRow(i, idx): # child survives - return True - # no surviving children -> drop the device row - return False - - # If the device row didn't match the text filter, just return - # False here; Qt will re-accept it automatically if any child - # is accepted (thanks to recursiveFilteringEnabled). - return False - - # 5. For non-Device rows, the decision is simply the text match - return text_match # type: ignore [no-any-return] - - def setReadOnlyVisible(self, show: bool) -> None: - """Set whether to show read-only properties.""" - if self.show_read_only != show: - self.show_read_only = show - self.invalidate() - - def setPreInitVisible(self, show: bool) -> None: - """Set whether to show pre-init properties.""" - if self.show_pre_init != show: - self.show_pre_init = show - self.invalidate() - - def setAllowedDeviceTypes(self, allowed: set[DeviceType]) -> None: - """Set the allowed device types.""" - if self.allowed != allowed: - self.allowed = allowed - self.invalidate() - - class DeviceTypeButtons(QToolBar): checkedDevicesChanged = Signal(set) readOnlyToggled = Signal(bool) @@ -218,16 +141,28 @@ def __init__(self, parent: QWidget | None = None) -> None: class DevicePropertySelector(QWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) - self._model = model = QDevicePropertyModel() - self._proxy = proxy = DeviceTypeFilter(allowed={DeviceType.Any}, parent=self) - proxy.setSourceModel(model) + self._dev_type_btns = DeviceTypeButtons(self) self._dev_type_btns.setIconSize(QSize(16, 16)) self._tb2 = DeviceFilterButtons(self) self._tb2.setIconSize(QSize(16, 16)) self.setStyleSheet("QToolBar { border: none; };") + self.tree = QTreeView(self) - self.tree.setModel(proxy) + + self._model = QDevicePropertyModel() + + # 1. Filter first - keeps device/property semantics intact + self._proxy = DeviceTypeFilter(allowed={DeviceType.Any}, parent=self) + self._proxy.setSourceModel(self._model) + + # 2. Then optionally flatten the (already-filtered) tree + self._flat_proxy = DevicePropertyFlatProxy() + self._flat_proxy.setSourceModel(self._proxy) + + # 3. The view consumes the flattening proxy + self.tree.setModel(self._flat_proxy) + self.tree.setSortingEnabled(True) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -236,12 +171,14 @@ def __init__(self, parent: QWidget | None = None) -> None: layout.addWidget(self._tb2) layout.addWidget(self.tree) - self._dev_type_btns.checkedDevicesChanged.connect(proxy.setAllowedDeviceTypes) + self._dev_type_btns.checkedDevicesChanged.connect( + self._proxy.setAllowedDeviceTypes + ) self._dev_type_btns.readOnlyToggled.connect(self._proxy.setReadOnlyVisible) self._dev_type_btns.preInitToggled.connect(self._proxy.setPreInitVisible) self._tb2.expandAllToggled.connect(self._expand_all) self._tb2.collapseAllToggled.connect(self.tree.collapseAll) - self._tb2.filterStringChanged.connect(proxy.setFilterFixedString) + self._tb2.filterStringChanged.connect(self._proxy.setFilterFixedString) def _expand_all(self) -> None: """Expand all items in the tree view.""" @@ -258,11 +195,14 @@ def setChecked(self, settings: Iterable[tuple[str, str, str]]) -> None: def setAvailableDevices(self, devices: Iterable[Device]) -> None: devices = list(devices) self._model.set_devices(devices) - self.tree.setColumnHidden(1, True) # Hide the second column (device type) - self.tree.setHeaderHidden(True) + # self.tree.setColumnHidden(1, True) # Hide the second column (device type) + # self.tree.setHeaderHidden(True) + if hh := self.tree.header(): + hh.setSectionResizeMode(hh.ResizeMode.ResizeToContents) dev_types = {d.type for d in devices} self._dev_type_btns.setVisibleDeviceTypes(dev_types) + # # hide some types that are often not immediately useful in this context # dev_types.difference_update( # {DeviceType.AutoFocus, DeviceType.Core, DeviceType.Camera} diff --git a/src/pymmcore_widgets/config_presets/_views/_device_type_filter_proxy.py b/src/pymmcore_widgets/config_presets/_views/_device_type_filter_proxy.py new file mode 100644 index 000000000..a421aa87f --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_views/_device_type_filter_proxy.py @@ -0,0 +1,85 @@ +from pymmcore_plus import DeviceType +from qtpy.QtCore import QModelIndex, QSortFilterProxyModel, Qt +from qtpy.QtWidgets import QWidget + +from pymmcore_widgets._models import Device, DevicePropertySetting + + +class DeviceTypeFilter(QSortFilterProxyModel): + def __init__(self, allowed: set[DeviceType], parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self.setRecursiveFilteringEnabled(True) + self.allowed = allowed # e.g. {"Camera", "Shutter"} + self.show_read_only = False + self.show_pre_init = False + + def _device_allowed_for_index(self, idx: QModelIndex) -> bool: + """Walk up to the closest Device ancestor and check its type.""" + while idx.isValid(): + data = idx.data(Qt.ItemDataRole.UserRole) + if isinstance(data, Device): + return DeviceType.Any in self.allowed or data.type in self.allowed + idx = idx.parent() # keep climbing + return True # no Device ancestor (root rows etc.) + + def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool: + if (sm := self.sourceModel()) is None: + return super().filterAcceptsRow(source_row, source_parent) # type: ignore [no-any-return] + + idx = sm.index(source_row, 0, source_parent) + + # 1. Bail out whole subtree when its Device type is disallowed + if not self._device_allowed_for_index(idx): + return False + + data = idx.data(Qt.ItemDataRole.UserRole) + + # 2. Per-property flags + if isinstance(data, DevicePropertySetting): + if data.is_read_only and not self.show_read_only: + return False + if data.is_pre_init and not self.show_pre_init: + return False + if data.is_advanced: + return False + + # 3. Text / regex filter (superclass logic) + text_match = super().filterAcceptsRow(source_row, source_parent) + + # 4. Special rule for Device rows: hide when it ends up child-less + if isinstance(data, Device): + # If the device name itself matches, keep it only if at least + # one child survives *after all rules above*. + if text_match: + for i in range(sm.rowCount(idx)): + if self.filterAcceptsRow(i, idx): # child survives + return True + # # no surviving children -> drop the device row + # return False + + # # If the device row didn't match the text filter, just return + # # False here; Qt will re-accept it automatically if any child + # # is accepted (thanks to recursiveFilteringEnabled). + # return False + + # 5. For non-Device rows, the decision is simply the text match + return text_match # type: ignore [no-any-return] + + def setReadOnlyVisible(self, show: bool) -> None: + """Set whether to show read-only properties.""" + if self.show_read_only != show: + self.show_read_only = show + self.invalidate() + + def setPreInitVisible(self, show: bool) -> None: + """Set whether to show pre-init properties.""" + if self.show_pre_init != show: + self.show_pre_init = show + self.invalidate() + + def setAllowedDeviceTypes(self, allowed: set[DeviceType]) -> None: + """Set the allowed device types.""" + if self.allowed != allowed: + self.allowed = allowed + self.invalidate() From c654c3f562b3abaf2600a34fda781c206aac5011 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 6 Jul 2025 08:43:22 -0400 Subject: [PATCH 56/70] remove flattening --- .../_models/_tree_flattening.py | 484 ------------------ 1 file changed, 484 deletions(-) delete mode 100644 src/pymmcore_widgets/_models/_tree_flattening.py diff --git a/src/pymmcore_widgets/_models/_tree_flattening.py b/src/pymmcore_widgets/_models/_tree_flattening.py deleted file mode 100644 index 3e356c106..000000000 --- a/src/pymmcore_widgets/_models/_tree_flattening.py +++ /dev/null @@ -1,484 +0,0 @@ -from __future__ import annotations - -from collections import defaultdict -from typing import Any - -from qtpy.QtCore import ( - QAbstractItemModel, - QModelIndex, - QObject, - QSortFilterProxyModel, - Qt, -) - - -class _Token: - """Unique, stable pointer stored in each QModelIndex.""" - - __slots__ = ("is_child", "top_row") - - def __init__(self, top_row: int, is_child: bool = False) -> None: - self.top_row = top_row - self.is_child = is_child - - -class TreeFlatteningProxy(QSortFilterProxyModel): - """A proxy model that flattens a tree model into a table view with expandable rows. - - This model allows you to specify a row depth, which determines how many rows are - shown in the flattened view. For example, if row_depth=0, it reverts to the original - model, while row_depth=1 shows the first level of children, and row_depth=2 shows - the second level of children. The model supports expandable rows, allowing users to - expand and collapse child rows based on the specified depth. - """ - - def __init__(self, parent: QObject | None = None, row_depth: int = 0) -> None: - super().__init__(parent) - # the row depth determines how many rows we show in the flattened view - # for example, if row_depth=0, we revert to the original model, - self._row_depth = row_depth - - # Store which rows are expandable and their children - self._expandable_rows: dict[int, list[list[tuple[int, int]]]] = {} - # mapping of depth -> (num_rows, num_columns) - self._num_leaves_at_depth = defaultdict[int, int](int) - # max depth of the tree model - self._max_depth = 0 - # one entry per proxy row - self._row_paths: list[list[tuple[int, int]]] = [] - - # stable pointers reused by createIndex - self._top_tokens: list[_Token] = [] - self._child_tokens: dict[tuple[int, int], _Token] = {} - - def set_row_depth(self, row_depth: int) -> None: - """Set the row depth for the flattened view.""" - self._row_depth = row_depth - self._rebuild() - - # ------------- QAbstractItemModel interface methods ------------- - - def headerData( - self, - section: int, - orientation: Qt.Orientation, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - """Return the header data for the given section and orientation.""" - if ( - orientation == Qt.Orientation.Horizontal - and role == Qt.ItemDataRole.DisplayRole - ): - return f"Level {section}" # Level 0 = root, Level N = leaf - return super().headerData(section, orientation, role) - - # def rowCount(self, parent: QModelIndex | None = None) -> int: - # """Return the number of rows under *parent* in the flattened view.""" - # if parent is None: - # parent = QModelIndex() - # if self._row_depth <= 0: - # return int(super().rowCount(parent)) - - # if not parent.isValid(): - # # root in proxy - every top-level row represents one path we stored - # return len(self._row_paths) - - # # Top-level rows have an **even** internalId; they may expose children. - # if (parent.internalId() & 1) == 0: - # children = self._expandable_rows.get(parent.row()) - # return len(children) if children is not None else 0 - - # # Child rows (odd internalId) never have further children in this proxy - # return 0 - - def hasChildren(self, parent: QModelIndex | None = None) -> bool: - return self.rowCount(parent) > 0 - - def columnCount(self, parent: QModelIndex | None = None) -> int: - """Return the number of columns for the given parent.""" - if parent is None: - parent = QModelIndex() - if self._row_depth <= 0: - return int(super().columnCount(parent)) - - # For flattened view, show columns up to row_depth + 1 - # This makes row_depth=1 show 2 columns, row_depth=2 show 3 columns, etc. - return self._row_depth + 1 - - def setSourceModel(self, source_model: QAbstractItemModel | None) -> None: - """Set the source model and rebuild whenever the source structure changes.""" - # Disconnect from the old model (if any) - old_model = self.sourceModel() - if old_model is not None: - try: - old_model.dataChanged.disconnect(self._rebuild) - old_model.rowsInserted.disconnect(self._rebuild) - old_model.rowsRemoved.disconnect(self._rebuild) - old_model.columnsInserted.disconnect(self._rebuild) - old_model.columnsRemoved.disconnect(self._rebuild) - old_model.layoutChanged.disconnect(self._rebuild) - old_model.modelReset.disconnect(self._rebuild) - except (TypeError, RuntimeError): - # Signals may already be disconnected or the model deleted - pass - - super().setSourceModel(source_model) - - # Connect to the new model (if provided) - if source_model is not None: - # All of these signals indicate that the structure of the source - # model has changed in a way that could invalidate our cached - # row/column paths - so trigger a rebuild. - for sig in ( - source_model.dataChanged, - source_model.rowsInserted, - source_model.rowsRemoved, - source_model.columnsInserted, - source_model.columnsRemoved, - source_model.layoutChanged, - source_model.modelReset, - ): - # We ignore all positional arguments from the signal. - sig.connect(lambda *_, **__: self._rebuild()) - - # Build initial flattened representation - self._rebuild() - - # ------------------------------------------------------------------ - # Index <-> Parent helpers - # - # * top-level internalId = row << 1 (always even) - # * child rows internalId = (row << 1)|1 (always odd) - # ------------------------------------------------------------------ - - # def index( - # self, - # row: int, - # column: int, - # parent: QModelIndex | None = None, - # ) -> QModelIndex: - # """Return proxy QModelIndex for (row, column, parent).""" - # if parent is None: - # parent = QModelIndex() - # if self._row_depth <= 0: - # return super().index(row, column, parent) - - # # ---------------- root → top-level rows ---------------- - # if not parent.isValid(): - # if ( - # row < 0 - # or row >= len(self._row_paths) - # or column < 0 - # or column >= self.columnCount() - # ): - # return QModelIndex() - # return self.createIndex(row, column, row << 1) # even id - - # # ---------------- top-level → child rows --------------- - # if (parent.internalId() & 1) == 0: # only top-level may have children - # top_row = parent.row() - # children = self._expandable_rows.get(top_row) - # if children is None: - # return QModelIndex() - # if ( - # row < 0 - # or row >= len(children) - # or column < 0 - # or column >= self.columnCount() - # ): - # return QModelIndex() - # return self.createIndex(row, column, (top_row << 1) | 1) # odd id - - # # children have no further descendants - # return QModelIndex() - - # def parent(self, index: QModelIndex) -> QModelIndex: - # """Return the parent of *index* in the flattened hierarchy.""" - # if self._row_depth <= 0: - # return super().parent(index) - - # if not index.isValid(): - # return QModelIndex() - - # internal_id = index.internalId() - - # # child-less top-level rows - # if (internal_id & 1) == 0: - # return QModelIndex() # root - - # # child → parent is encoded in high bits - # parent_row = internal_id >> 1 - # if 0 <= parent_row < len(self._row_paths): - # return self.createIndex(parent_row, 0, parent_row << 1) - - # return QModelIndex() - - def mapToSource(self, proxy_index: QModelIndex) -> QModelIndex: - """Translate a proxy index back to the source model.""" - if self._row_depth <= 0 or not (src_model := self.sourceModel()): - return super().mapToSource(proxy_index) - - if not proxy_index.isValid(): - return QModelIndex() - - row, col = proxy_index.row(), proxy_index.column() - - tok = proxy_index.internalPointer() - is_child = isinstance(tok, _Token) and tok.is_child - - if not is_child: - # ---- top-level proxy row ---- - if row >= len(self._row_paths): - return QModelIndex() - - path = self._row_paths[row] - if col >= len(path): # beyond recorded depth - return QModelIndex() - - src = QModelIndex() - for r, c in path[: col + 1]: - src = src_model.index(r, c, src) - return src - - # ---- child proxy row ---- - parent_row = tok.top_row - children = self._expandable_rows.get(parent_row) - if children is None or row >= len(children): - return QModelIndex() - - path = children[row] - if col == 0: - return QModelIndex() # intentionally empty - indentation column - if col != self._row_depth: - return QModelIndex() # other columns unused for child rows - - src = QModelIndex() - for r, c in path: - src = src_model.index(r, c, src) - return src - - def mapFromSource(self, source_index: QModelIndex) -> QModelIndex: - """Translate a source index to the corresponding proxy index.""" - if self._row_depth <= 0 or not self.sourceModel(): - return super().mapFromSource(source_index) - - if not source_index.isValid(): - return QModelIndex() - - # Build path root→…→source_index - path: list[tuple[int, int]] = [] - cur = source_index - while cur.isValid(): - path.append((cur.row(), cur.column())) - cur = cur.parent() - path.reverse() - - # We flattened the tree by collecting *row_depth*-deep paths into - # `self._row_paths`. Each proxy row therefore has: - # - # column 0 → node at depth 0 (device) - # column 1 → node at depth 1 (property) when row_depth == 1 - # - # For a given *source* index we need to decide which proxy column - # represents it. - for proxy_row, row_path in enumerate(self._row_paths): - if path == row_path: # exact match → deepest column - column = len(path) - 1 # row_depth - return self.createIndex(proxy_row, column, self._top_tokens[proxy_row]) - - # Is the source index the ancestor at depth-0 of this row? - # (e.g. device row when the proxy row represents a property) - if path == row_path[: len(path)]: - column = len(path) - 1 # 0 for device - return self.createIndex(proxy_row, column, self._top_tokens[proxy_row]) - - return QModelIndex() - - def _rebuild(self) -> None: - self.beginResetModel() - self._max_depth = 0 - self._top_tokens.clear() - self._child_tokens.clear() - # one entry per proxy row - self._row_paths = [] - # mapping of depth -> (num_rows, num_columns) - self._num_leaves_at_depth = defaultdict[int, int](int) - self._expandable_rows = {} - if src := self.sourceModel(): - self._collect_model_shape(src) - self.endResetModel() - - def _collect_model_shape( - self, - model: QAbstractItemModel, - parent: QModelIndex | None = None, - depth: int = 0, - stack: list[tuple[int, int]] | None = None, - ) -> None: - if parent is None: - parent = QModelIndex() - if stack is None: - stack = [] - - rows = model.rowCount(parent) - self._num_leaves_at_depth[depth] += rows - self._max_depth = max(self._max_depth, depth) - - for r in range(rows): - child = model.index(r, 0, parent) # tree is in column 0 - pair_path = [*stack, (r, 0)] - - # Add node if we're at target depth OR - # if it's a terminal node before target depth - should_add_node = ( - depth == self._row_depth # At target depth - or ( - depth < self._row_depth and not model.hasChildren(child) - ) # Terminal before target - ) - - if should_add_node: - # Add the row to the flattened view - row_index = len(self._row_paths) - self._row_paths.append(pair_path) - - # If this row has children and we're at target depth, - # store them for expansion - if depth == self._row_depth and model.hasChildren(child): - children = [] - child_rows = model.rowCount(child) - for child_r in range(child_rows): - child_path = [*pair_path, (child_r, 0)] - children.append(child_path) - self._expandable_rows[row_index] = children - - # Continue if we haven't reached target depth and node has children - if depth < self._row_depth and model.hasChildren(child): - self._collect_model_shape(model, child, depth + 1, pair_path) - - # def sort( - # self, - # column: int, - # order: Qt.SortOrder = Qt.SortOrder.AscendingOrder, - # ) -> None: - # """Sort the proxy model by the specified column.""" - # if self._row_depth <= 0: - # return super().sort(column, order) # type: ignore[no-any-return] - - # if column < 0 or column >= self.columnCount(): - # return # Invalid column - - # # Emit layoutAboutToBeChanged signal - # self.layoutAboutToBeChanged.emit() - - # # Sort our _row_paths based on the data in the specified column - # def get_sort_key(row_index: int) -> str: - # """Get the sort key for a row at the given index.""" - # proxy_idx = self.index(row_index, column) - # data = self.data(proxy_idx, Qt.ItemDataRole.DisplayRole) - # return str(data) if data is not None else "" - - # # Create list of (original_index, sort_key) pairs - # indexed_rows = [(i, get_sort_key(i)) for i in range(len(self._row_paths))] - - # # Sort by the sort key - # reverse_order = order == Qt.SortOrder.DescendingOrder - # indexed_rows.sort(key=lambda x: x[1], reverse=reverse_order) - - # # Reorder _row_paths and _expandable_rows based on sorted order - # old_row_paths = self._row_paths.copy() - # old_expandable_rows = self._expandable_rows.copy() - - # self._row_paths = [] - # self._expandable_rows = {} - - # for new_index, (old_index, _) in enumerate(indexed_rows): - # # Copy the row path - # self._row_paths.append(old_row_paths[old_index]) - - # # Copy expandable rows mapping with new index - # if old_index in old_expandable_rows: - # self._expandable_rows[new_index] = old_expandable_rows[old_index] - - # # Emit layoutChanged signal - # self.layoutChanged.emit() - # ------------------------------------------------------------------ - # Index / Parent helpers - we use _Token pointers, never integers - # ------------------------------------------------------------------ - - def index( - self, row: int, column: int, parent: QModelIndex | None = None - ) -> QModelIndex: - if parent is None: - parent = QModelIndex() - if self._row_depth <= 0: - return super().index(row, column, parent) - - # ---------- root → top-level ---------- - if not parent.isValid(): - if not (0 <= row < len(self._row_paths)) or not ( - 0 <= column < self.columnCount() - ): - return QModelIndex() - # reuse token - while row >= len(self._top_tokens): - self._top_tokens.append(_Token(len(self._top_tokens))) - return self.createIndex(row, column, self._top_tokens[row]) - - # ---------- top-level → child ---------- - p_tok = parent.internalPointer() - if isinstance(p_tok, _Token) and not p_tok.is_child: - top_row = p_tok.top_row - children = self._expandable_rows.get(top_row) - if ( - children is None - or not (0 <= row < len(children)) - or not (0 <= column < self.columnCount()) - ): - return QModelIndex() - key = (top_row, row) - token = self._child_tokens.get(key) - if token is None: - token = _Token(top_row, True) - self._child_tokens[key] = token - return self.createIndex(row, column, token) - - return QModelIndex() # a child has no grandchildren - - def parent(self, index: QModelIndex) -> QModelIndex: - if self._row_depth <= 0: - return super().parent(index) - if not index.isValid(): - return QModelIndex() - - tok = index.internalPointer() - if not isinstance(tok, _Token) or not tok.is_child: - return QModelIndex() # top-level rows → root - - top_row = tok.top_row - if 0 <= top_row < len(self._top_tokens): - return self.createIndex(top_row, 0, self._top_tokens[top_row]) - return QModelIndex() - - def sibling(self, row: int, column: int, index: QModelIndex) -> QModelIndex: - """Return a sibling index for the given row and column.""" - if not index.isValid(): - return QModelIndex() - - parent_index = self.parent(index) - return self.index(row, column, parent_index) - - def rowCount(self, parent: QModelIndex | None = None) -> int: - if parent is None: - parent = QModelIndex() - if self._row_depth <= 0: - return int(super().rowCount(parent)) - - if not parent.isValid(): - return len(self._row_paths) - - tok = parent.internalPointer() - if isinstance(tok, _Token) and not tok.is_child: - ch = self._expandable_rows.get(tok.top_row) - return len(ch) if ch else 0 - return 0 From 32f6002f0d45b7f2e6b59710bf41afe365ce5c88 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 6 Jul 2025 11:05:54 -0400 Subject: [PATCH 57/70] feat: add help dialog and HTML documentation for configuration groups editor refactor: improve icon handling and toolbar actions in config groups editor refactor: enhance QConfigGroupsModel to manage channel groups refactor: streamline device property selector and view mode toggling --- examples/config_groups_editor.py | 2 +- src/pymmcore_widgets/_help/__init__.py | 0 .../_help/_config_groups_help.py | 42 +++++ .../_help/config_groups_help.html | 90 +++++++++ src/pymmcore_widgets/_icons.py | 29 ++- .../_models/_base_tree_model.py | 6 + .../_models/_core_functions.py | 3 +- .../_models/_py_config_model.py | 16 +- .../_models/_q_config_model.py | 81 +++++++- .../_models/_q_device_prop_model.py | 40 +++- .../_views/_config_groups_editor.py | 175 +++++++++++------- .../_views/_config_presets_table.py | 12 +- .../_views/_device_property_selector.py | 111 ++++++++--- .../_views/_group_preset_selector.py | 11 +- 14 files changed, 493 insertions(+), 125 deletions(-) create mode 100644 src/pymmcore_widgets/_help/__init__.py create mode 100644 src/pymmcore_widgets/_help/_config_groups_help.py create mode 100644 src/pymmcore_widgets/_help/config_groups_help.html diff --git a/examples/config_groups_editor.py b/examples/config_groups_editor.py index 3a0c7cc97..4e72108b7 100644 --- a/examples/config_groups_editor.py +++ b/examples/config_groups_editor.py @@ -10,7 +10,7 @@ core.loadSystemConfiguration() cfg = ConfigGroupsEditor.create_from_core(core) -cfg.setCurrentPreset("Channel", "FITC") +# cfg.setCurrentPreset("Channel", "FITC") cfg.show() cfg.resize(1200, 800) diff --git a/src/pymmcore_widgets/_help/__init__.py b/src/pymmcore_widgets/_help/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pymmcore_widgets/_help/_config_groups_help.py b/src/pymmcore_widgets/_help/_config_groups_help.py new file mode 100644 index 000000000..8d1207c2e --- /dev/null +++ b/src/pymmcore_widgets/_help/_config_groups_help.py @@ -0,0 +1,42 @@ +from pathlib import Path + +from qtpy.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFrame, + QTextBrowser, + QVBoxLayout, + QWidget, +) + +HELP_DOC = Path(__file__).parent / "config_groups_help.html" + + +class ConfigGroupsHelpDialog(QDialog): + """Help dialog for the Config Groups Editor.""" + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setWindowTitle("Config Groups and Presets") + self.setModal(True) + + # Add help content here + help_text = QTextBrowser(self) + help_text.setReadOnly(True) + help_text.setAcceptRichText(True) + help_text.setHtml(HELP_DOC.read_text()) + help_text.setFrameShape(QFrame.Shape.NoFrame) + # enable links + help_text.setOpenExternalLinks(True) + + # make the background match the dialog + pal = self.palette() + pal.setColor(pal.ColorRole.Base, pal.color(pal.ColorRole.Window)) + help_text.setPalette(pal) + + btn_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) + btn_box.accepted.connect(self.accept) + + layout = QVBoxLayout(self) + layout.addWidget(help_text) + layout.addWidget(btn_box) diff --git a/src/pymmcore_widgets/_help/config_groups_help.html b/src/pymmcore_widgets/_help/config_groups_help.html new file mode 100644 index 000000000..2be96bd43 --- /dev/null +++ b/src/pymmcore_widgets/_help/config_groups_help.html @@ -0,0 +1,90 @@ + + + + + Micro-Manager Configuration Groups and Presets - Quick Guide + + +

Configuration Groups and Presets

+ +
+ +

+ Each Group contains named Presets that + define a set of Device Property values to be applied to + the hardware. +

+ +

Typical Workflow

+ +
    +
  1. + Add a configuration group (e.g. "Channel") using the + Add Group button at the top. +
  2. +
  3. + Add a preset to the group (e.g. "DAPI"), by selecting + the group and clicking the Add Preset button. +
  4. +
  5. + Select device properties to include in the preset (e.g. + "Filter Wheel Position", "Light Source Power") using the device property + selector at the right. +
  6. +
  7. + Set the device properties to desired values for the + currently selected preset in the Presets Table at the bottom. +
  8. +
  9. + Repeat steps 2-4 to add more presets to the group (e.g. + "FITC", "Cy3", "Cy5"). You may Duplicate an existing preset to + copy its device property values if another preset is similar. +
  10. +
+ +

Key Building Blocks

+ +

1. Devices

+

+ Physical hardware (camera, filter wheel, stage, light source). Each device + exposes one or more properties that can be changed or + read. +

+ +

2. Device Properties

+

+ A single adjustable parameter on a device (e.g. + Exposure, Position). +

+ +

3. Configuration Presets

+

+ A preset is a snapshot of specific values for a set of device + properties. Activating a preset sets each device property + to its stored value. +

+

+ For example, you might have a FITC preset that sets the + appropriate filter wheel positions and light sources for imaging the FITC + channel. +

+ +

4. Configuration Groups

+

+ A configuration group is a collection of presets. It is + customary (but not mandatory) for each preset in a group to share + the same set of device properties. +

+

+ For example, you might have a Channel group that includes presets + for each optical configuration preset (DAPI, FITC, + Cy3, Cy5). +

+ + diff --git a/src/pymmcore_widgets/_icons.py b/src/pymmcore_widgets/_icons.py index 95f2d9ac2..ea4f112b6 100644 --- a/src/pymmcore_widgets/_icons.py +++ b/src/pymmcore_widgets/_icons.py @@ -1,5 +1,7 @@ from __future__ import annotations +from enum import Enum + from pymmcore_plus import CMMCorePlus, DeviceType from superqt import QIconifyIcon @@ -23,10 +25,29 @@ DeviceType.Serial: "mdi:serial-port", } -PROPERTY_FLAG_ICON: dict[str, str] = { - "read-only": "fluent:edit-off-20-regular", - "pre-init": "mynaui:letter-p-diamond", -} + +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" + + def icon(self, color: str = "#333") -> QIconifyIcon: + return QIconifyIcon(self.value, color=color) + + def __str__(self) -> str: + return self.value def get_device_icon( diff --git a/src/pymmcore_widgets/_models/_base_tree_model.py b/src/pymmcore_widgets/_models/_base_tree_model.py index d82a60da9..4ae58b5c6 100644 --- a/src/pymmcore_widgets/_models/_base_tree_model.py +++ b/src/pymmcore_widgets/_models/_base_tree_model.py @@ -67,6 +67,12 @@ def __init__( # 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) diff --git a/src/pymmcore_widgets/_models/_core_functions.py b/src/pymmcore_widgets/_models/_core_functions.py index cc23de9c7..99d9e2063 100644 --- a/src/pymmcore_widgets/_models/_core_functions.py +++ b/src/pymmcore_widgets/_models/_core_functions.py @@ -28,8 +28,9 @@ def _get_device(core: CMMCorePlus, label: str) -> Device: 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) + 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 diff --git a/src/pymmcore_widgets/_models/_py_config_model.py b/src/pymmcore_widgets/_models/_py_config_model.py index 23c3bf440..37205917c 100644 --- a/src/pymmcore_widgets/_models/_py_config_model.py +++ b/src/pymmcore_widgets/_models/_py_config_model.py @@ -1,11 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, ClassVar +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 @@ -101,14 +103,12 @@ def as_tuple(self) -> tuple[str, str, str]: return (self.device_label, self.property_name, self.value) @property - def iconify_key(self) -> str | None: + def iconify_key(self) -> StandardIcon | None: """Return an iconify key for the device type.""" - from pymmcore_widgets._icons import PROPERTY_FLAG_ICON - if self.is_read_only: - return PROPERTY_FLAG_ICON["read-only"] + return StandardIcon.READ_ONLY elif self.is_pre_init: - return PROPERTY_FLAG_ICON["pre-init"] + return StandardIcon.PRE_INIT return None @model_validator(mode="before") @@ -170,6 +170,8 @@ class ConfigGroup(_BaseModel): name: str presets: dict[str, ConfigPreset] = Field(default_factory=dict) + is_channel_group: bool = False + @property def children(self) -> tuple[ConfigPreset, ...]: """Return the presets in the group.""" @@ -192,6 +194,8 @@ class PixelSizeConfigs(ConfigGroup): 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() diff --git a/src/pymmcore_widgets/_models/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py index 33ea8b8fe..07bcf8d7a 100644 --- a/src/pymmcore_widgets/_models/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -1,14 +1,16 @@ from __future__ import annotations +import warnings from copy import deepcopy from enum import IntEnum from typing import TYPE_CHECKING, Any, cast 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 @@ -72,13 +74,16 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A 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) + return StandardIcon.CONFIG_GROUP.icon().pixmap(16, 16) if node.is_preset: - return QIcon.fromTheme("document") + return StandardIcon.CONFIG_PRESET.icon().pixmap(16, 16) if node.is_setting: setting = cast("DevicePropertySetting", node.payload) - if icon := get_device_icon(setting.device_label, color="gray"): - return icon.pixmap(16, 16) + 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): @@ -200,7 +205,7 @@ 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, suffix="") group = ConfigGroup(name=name) @@ -226,7 +231,7 @@ def duplicate_group( # 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): @@ -255,6 +260,46 @@ def duplicate_preset( return self.index(row, 0, group_idx) 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 ---------------------------------------------------------- def removeRows( @@ -290,8 +335,26 @@ def removeRows( self.endRemoveRows() return True - def remove(self, idx: QModelIndex) -> None: + 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, + ) + if msg != QMessageBox.StandardButton.Yes: + return self.removeRows(idx.row(), 1, idx.parent()) # ------------------------------------------------------------------ diff --git a/src/pymmcore_widgets/_models/_q_device_prop_model.py b/src/pymmcore_widgets/_models/_q_device_prop_model.py index f0ddbc2c2..34af4d054 100644 --- a/src/pymmcore_widgets/_models/_q_device_prop_model.py +++ b/src/pymmcore_widgets/_models/_q_device_prop_model.py @@ -142,6 +142,19 @@ 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.""" @@ -234,6 +247,29 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A return 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 + + row = index.row() + if row >= len(self._rows): + return False + + drow, prow = self._rows[row] + col = index.column() + + if col == 0: + device_idx = self._source_model.index(drow, 0) + return bool(self._source_model.setData(device_idx, value, role)) + elif col == 1: + device_idx = self._source_model.index(drow, 0) + if device_idx.isValid(): + prop_idx = self._source_model.index(prow, 0, device_idx) + return bool(self._source_model.setData(prop_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 @@ -252,7 +288,9 @@ def flags(self, index: QModelIndex) -> Qt.ItemFlag: device_idx = self._source_model.index(drow, 0) if device_idx.isValid(): prop_idx = self._source_model.index(prow, 0, device_idx) - return self._source_model.flags(prop_idx) + return ( + self._source_model.flags(prop_idx) | Qt.ItemFlag.ItemIsUserCheckable + ) return Qt.ItemFlag.NoItemFlags diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index 97b255b5f..76c134b9c 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -2,7 +2,7 @@ from contextlib import contextmanager from enum import Enum, auto -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from qtpy.QtCore import QModelIndex, QSize, Qt, Signal from qtpy.QtWidgets import ( @@ -15,6 +15,7 @@ ) from superqt import QIconifyIcon +from pymmcore_widgets._icons import StandardIcon from pymmcore_widgets._models import ( ConfigGroup, QConfigGroupsModel, @@ -46,6 +47,91 @@ class LayoutMode(Enum): # ----------------------------------------------------------------------------- +class _ConfigEditorToolbar(QToolBar): + def __init__(self, parent: ConfigGroupsEditor) -> None: + super().__init__(parent) + # tool bar -------------------------------------------------------------- + + self.setIconSize(QSize(22, 22)) + self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + + # Create exclusive action group for view modes + view_action_group = QActionGroup(self) + + # Column View action + column_icon = QIconifyIcon("fluent:layout-column-two-24-regular") + if column_act := self.addAction( + column_icon, "Column View", parent._group_preset_sel.showColumnView + ): + column_act.setCheckable(True) + column_act.setChecked(True) + view_action_group.addAction(column_act) + + # Tree View action + if tree_act := self.addAction( + StandardIcon.TREE.icon(), "Tree View", parent._group_preset_sel.showTreeView + ): + tree_act.setCheckable(True) + view_action_group.addAction(tree_act) + + self.addAction( + StandardIcon.FOLDER_ADD.icon(), + "Add Group", + parent._model.add_group, + ) + self.addAction( + StandardIcon.DOCUMENT_ADD.icon(), + "Add Preset", + parent._add_preset_to_current_group, + ) + self.addAction( + StandardIcon.DELETE.icon(), + "Remove", + parent._group_preset_sel.removeSelected, + ) + self.addAction( + StandardIcon.COPY.icon(), + "Duplicate", + parent._group_preset_sel.duplicateSelected, + ) + self.addSeparator() + self.set_channel_action = cast( + "QAction", + self.addAction( + StandardIcon.CHANNEL_GROUP.icon(), + "Set Channel Group", + ), + ) + + @self.set_channel_action.triggered.connect # type: ignore[misc] + def _on_set_channel_group() -> None: + parent._group_preset_sel.setCurrentGroupAsChannelGroup() + self.set_channel_action.setEnabled(False) + + spacer = QWidget(self) + spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.addWidget(spacer) + + if act := self.addAction( + StandardIcon.HELP.icon(), + "Help", + parent._show_help, + ): + act.setToolTip("Show help") + + icon = QIconifyIcon( + "fluent:layout-row-two-split-top-focus-bottom-16-filled", color="#666" + ) + icon.addKey( + "fluent:layout-column-two-split-left-focus-right-16-filled", + state=QIconifyIcon.State.On, + color="#666", + ) + if act := self.addAction(icon, "Layout", parent.setLayoutMode): + act.setToolTip("Toggle layout mode") + act.setCheckable(True) + + class ConfigGroupsEditor(QWidget): """Widget composed of two QListViews backed by a single tree model. @@ -101,7 +187,8 @@ def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._model = QConfigGroupsModel() - # ------------------------------------------------------------------ + # widgets ------------------------------------------------------------- + # The GroupPresetSelector can switch between 2-list and tree views: # ┌───────────────┬───────────────┬───────────────┐ # │ groups │ presets │ ... │ @@ -117,75 +204,14 @@ def __init__(self, parent: QWidget | None = None) -> None: self._group_preset_sel = GroupPresetSelector(self) self._group_preset_sel.setModel(self._model) - # ------------------------------------------------------------------ - self._prop_selector = DevicePropertySelector() self._preset_table = ConfigPresetsTable(self) self._preset_table.setModel(self._model) self._preset_table.setGroup("Channel") - # tool bar -------------------------------------------------------------- - - self._tb = QToolBar(self) - self._tb.setIconSize(QSize(22, 22)) - self._tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) - - # Create exclusive action group for view modes - view_action_group = QActionGroup(self) - - # Column View action - column_icon = QIconifyIcon("fluent:layout-column-two-24-regular") - if column_act := self._tb.addAction( - column_icon, "Column View", self._group_preset_sel.showColumnView - ): - column_act.setCheckable(True) - column_act.setChecked(True) - view_action_group.addAction(column_act) - - # Tree View action - tree_icon = QIconifyIcon("fluent:list-bar-tree-20-regular", color="#666") - if tree_act := self._tb.addAction( - tree_icon, "Tree View", self._group_preset_sel.showTreeView - ): - tree_act.setCheckable(True) - view_action_group.addAction(tree_act) - - self._tb.addAction( - QIconifyIcon("fluent:folder-add-24-regular"), - "Add Group", - self._model.add_group, - ) - self._tb.addAction( - QIconifyIcon("fluent:document-add-24-regular"), - "Add Preset", - self._add_preset_to_current_group, - ) - self._tb.addAction( - QIconifyIcon("fluent:delete-24-regular"), - "Remove", - self._group_preset_sel.removeSelected, - ) - self._tb.addAction( - QIconifyIcon("fluent:save-copy-24-regular"), - "Duplicate", - self._group_preset_sel.duplicateSelected, - ) - - spacer = QWidget(self._tb) - spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - self._tb.addWidget(spacer) - - icon = QIconifyIcon( - "fluent:layout-row-two-split-top-focus-bottom-16-filled", color="#666" - ) - icon.addKey( - "fluent:layout-column-two-split-left-focus-right-16-filled", - state=QIconifyIcon.State.On, - color="#666", - ) - if act := self._tb.addAction(icon, "Toggle Layout", self.setLayoutMode): - act.setCheckable(True) + # define this after the other widgets so that it can connect to their slots + self._tb = _ConfigEditorToolbar(self) # layout ------------------------------------------------------------ @@ -213,6 +239,13 @@ def _on_group_changed(self, current: QModelIndex, previous: QModelIndex) -> None self._preset_table.view.stretchHeaders() self._preset_table.view.openPersistentEditors() + if current.isValid(): + group = current.data(Qt.ItemDataRole.UserRole) + if isinstance(group, ConfigGroup) and group.is_channel_group: + self._tb.set_channel_action.setEnabled(False) + else: + self._tb.set_channel_action.setEnabled(True) + def _on_preset_changed(self, current: QModelIndex, previous: QModelIndex) -> None: """Called when the preset selection in the GroupPresetSelector changes.""" if not current.isValid(): @@ -369,6 +402,18 @@ def _set_splitter_sizes( main_splitter.setSizes(top_splits) inner_splitter.setSizes(left_heights) + def _show_help(self) -> None: + """Show help for this widget.""" + from pymmcore_widgets._help._config_groups_help import ConfigGroupsHelpDialog + + dialog = ConfigGroupsHelpDialog(self) + size = ( + (self.size() * 0.8).expandedTo(QSize(600, 600)).boundedTo(QSize(800, 800)) + ) + dialog.resize(size) + dialog.setWindowFlags(Qt.WindowType.Sheet | Qt.WindowType.WindowCloseButtonHint) + dialog.exec() + # ------------------------------------------------------------------ # Property-table sync # ------------------------------------------------------------------ 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 a9f63b050..84b3c8380 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py @@ -12,8 +12,8 @@ QTransposeProxyModel, ) from qtpy.QtWidgets import QTableView, QToolBar, QVBoxLayout, QWidget -from superqt import QIconifyIcon +from pymmcore_widgets._icons import StandardIcon from pymmcore_widgets._models import ConfigGroupPivotModel, QConfigGroupsModel from ._property_setting_delegate import PropertySettingDelegate @@ -147,17 +147,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) @@ -184,7 +182,7 @@ def setGroup(self, group_name_or_index: str | QModelIndex) -> None: def _on_remove_action(self) -> None: if not self.view.isTransposed(): source_idx = self._get_selected_preset_index() - self.view.sourceModel().remove(source_idx) + self.view.sourceModel().remove(source_idx, ask_confirmation=True) # TODO: handle transposed case def _on_duplicate_action(self) -> None: diff --git a/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py index 14024ce88..687ba5e69 100644 --- a/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py +++ b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, cast from pymmcore_plus import DeviceType -from qtpy.QtCore import QModelIndex, QSize, Signal +from qtpy.QtCore import QModelIndex, QSize from qtpy.QtWidgets import ( QLineEdit, QSizePolicy, @@ -14,7 +14,7 @@ ) from superqt import QIconifyIcon -from pymmcore_widgets._icons import DEVICE_TYPE_ICON, PROPERTY_FLAG_ICON +from pymmcore_widgets._icons import DEVICE_TYPE_ICON, StandardIcon from pymmcore_widgets._models import Device, QDevicePropertyModel from pymmcore_widgets._models._q_device_prop_model import DevicePropertyFlatProxy @@ -23,11 +23,13 @@ if TYPE_CHECKING: from collections.abc import Iterable + from PyQt6.QtCore import pyqtSignal as Signal from PyQt6.QtGui import QAction +else: + from qtpy.QtCore import QModelIndex, Signal -# TODO: Allow GUI control of parameters -class DeviceTypeButtons(QToolBar): +class _DeviceButtonToolbar(QToolBar): checkedDevicesChanged = Signal(set) readOnlyToggled = Signal(bool) preInitToggled = Signal(bool) @@ -57,7 +59,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self.act_show_read_only = cast( "QAction", self.addAction( - QIconifyIcon(PROPERTY_FLAG_ICON["read-only"], color="gray"), + StandardIcon.READ_ONLY.icon(color="gray"), "Show Read-Only Properties", self.readOnlyToggled, ), @@ -65,7 +67,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self.act_show_pre_init = cast( "QAction", self.addAction( - QIconifyIcon(PROPERTY_FLAG_ICON["pre-init"], color="gray"), + StandardIcon.PRE_INIT.icon(color="gray"), "Show Pre-Init Properties", self.preInitToggled, ), @@ -104,20 +106,41 @@ def checkedDeviceTypes(self) -> set[DeviceType]: } -class DeviceFilterButtons(QToolBar): - """A toolbar with buttons to filter device types.""" +class _PropertySearchToolbar(QToolBar): + """A toolbar with expand/collapse all buttons and a search box.""" expandAllToggled = Signal() collapseAllToggled = Signal() + viewModeToggled = Signal(bool) # True for TreeView, False for TableView filterStringChanged = Signal(str) def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) + + # Add toggle button for view mode + self.act_toggle_view = cast( + "QAction", + self.addAction( + StandardIcon.TABLE.icon(color="gray"), + "Switch to Tree View", + self._toggle_view_mode, + ), + ) + self.act_toggle_view.setCheckable(True) + self.act_toggle_view.setChecked(False) # Default to TableView + + self._le = QLineEdit(self) + self._le.setMinimumWidth(160) + self._le.setClearButtonEnabled(True) + self._le.setPlaceholderText("Search properties...") + self._le.textChanged.connect(self.filterStringChanged) + self.addWidget(self._le) + self.act_expand = cast( "QAction", self.addAction( - QIconifyIcon("mdi:expand-horizontal", color="gray"), + StandardIcon.EXPAND.icon(), "Expand all", self.expandAllToggled, ), @@ -125,42 +148,55 @@ def __init__(self, parent: QWidget | None = None) -> None: self.act_collapse = cast( "QAction", self.addAction( - QIconifyIcon("mdi:collapse-horizontal", color="gray"), + StandardIcon.COLLAPSE.icon(), "Collapse all", self.collapseAllToggled, ), ) - self._le = QLineEdit(self) - self._le.setMinimumWidth(160) - self._le.setClearButtonEnabled(True) - self._le.setPlaceholderText("Search properties...") - self._le.textChanged.connect(self.filterStringChanged) - self.addWidget(self._le) + # Initially hide expand/collapse actions (TableView is default) + self.act_expand.setVisible(False) + self.act_collapse.setVisible(False) + + def _toggle_view_mode(self) -> None: + """Toggle between TreeView and TableView.""" + is_tree_view = self.act_toggle_view.isChecked() + + # Update button icon and tooltip + if is_tree_view: + self.act_toggle_view.setIcon(StandardIcon.TREE.icon()) + self.act_toggle_view.setText("Switch to Table View") + else: + self.act_toggle_view.setIcon(StandardIcon.TABLE.icon()) + self.act_toggle_view.setText("Switch to Tree View") + + # Show/hide expand/collapse actions + self.act_expand.setVisible(is_tree_view) + self.act_collapse.setVisible(is_tree_view) + + # Emit signal + self.viewModeToggled.emit(is_tree_view) class DevicePropertySelector(QWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) - self._dev_type_btns = DeviceTypeButtons(self) + self._dev_type_btns = _DeviceButtonToolbar(self) self._dev_type_btns.setIconSize(QSize(16, 16)) - self._tb2 = DeviceFilterButtons(self) + self._tb2 = _PropertySearchToolbar(self) self._tb2.setIconSize(QSize(16, 16)) self.setStyleSheet("QToolBar { border: none; };") - self.tree = QTreeView(self) - self._model = QDevicePropertyModel() - # 1. Filter first - keeps device/property semantics intact - self._proxy = DeviceTypeFilter(allowed={DeviceType.Any}, parent=self) - self._proxy.setSourceModel(self._model) + self._filter_proxy = DeviceTypeFilter(allowed={DeviceType.Any}, parent=self) + self._filter_proxy.setSourceModel(self._model) - # 2. Then optionally flatten the (already-filtered) tree self._flat_proxy = DevicePropertyFlatProxy() - self._flat_proxy.setSourceModel(self._proxy) + self._flat_proxy.setSourceModel(self._filter_proxy) - # 3. The view consumes the flattening proxy + self.tree = QTreeView(self) + # Start with TableView (flat proxy model) self.tree.setModel(self._flat_proxy) self.tree.setSortingEnabled(True) @@ -172,18 +208,33 @@ def __init__(self, parent: QWidget | None = None) -> None: layout.addWidget(self.tree) self._dev_type_btns.checkedDevicesChanged.connect( - self._proxy.setAllowedDeviceTypes + self._filter_proxy.setAllowedDeviceTypes + ) + self._dev_type_btns.readOnlyToggled.connect( + self._filter_proxy.setReadOnlyVisible ) - self._dev_type_btns.readOnlyToggled.connect(self._proxy.setReadOnlyVisible) - self._dev_type_btns.preInitToggled.connect(self._proxy.setPreInitVisible) + self._dev_type_btns.preInitToggled.connect(self._filter_proxy.setPreInitVisible) self._tb2.expandAllToggled.connect(self._expand_all) self._tb2.collapseAllToggled.connect(self.tree.collapseAll) - self._tb2.filterStringChanged.connect(self._proxy.setFilterFixedString) + self._tb2.filterStringChanged.connect(self._filter_proxy.setFilterFixedString) + self._tb2.viewModeToggled.connect(self._toggle_view_mode) def _expand_all(self) -> None: """Expand all items in the tree view.""" self.tree.expandRecursively(QModelIndex()) + def _toggle_view_mode(self, is_tree_view: bool) -> None: + """Toggle between TreeView and TableView modes.""" + if is_tree_view: + # Switch to TreeView: use filter proxy directly + self.tree.setModel(self._filter_proxy) + self.tree.setColumnHidden(1, True) # Hide the second column (device type) + self.tree.expandAll() + else: + # Switch to TableView: use flat proxy + self.tree.setModel(self._flat_proxy) + self.tree.setColumnHidden(1, False) # Show the second column (property) + def clear(self) -> None: """Clear the current selection.""" # self.table.setValue([]) diff --git a/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py b/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py index 84e9e2769..4a0fb2c37 100644 --- a/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py +++ b/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py @@ -151,11 +151,15 @@ def _selected_index(self) -> QModelIndex: return self.group_list.currentIndex() elif self.preset_list.hasFocus(): return self.preset_list.currentIndex() + elif self.config_groups_tree.hasFocus(): + return self.config_groups_tree.currentIndex() return QModelIndex() def removeSelected(self) -> None: if self._model: - self._model.remove(self._selected_index()) + self._model.remove( + self._selected_index(), ask_confirmation=True, parent=self + ) def duplicateSelected(self) -> None: if self._model: @@ -179,6 +183,11 @@ def toggleView(self) -> None: else: self.showColumnView() + def setCurrentGroupAsChannelGroup(self) -> None: + """Set the currently selected group as the channel group in the model.""" + if (model := self._model) and (idx := self.currentGroup()).isValid(): + model.set_channel_group(idx) + def isTreeViewActive(self) -> bool: """Return True if tree view is currently active.""" return bool(self.currentIndex() == 1) From dda6f47b638f1ad9f7aacfc6e2f9ad4dc0f90ae2 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 6 Jul 2025 11:33:38 -0400 Subject: [PATCH 58/70] fix persistent --- .../_models/_config_group_pivot_model.py | 65 ++++++++++++++----- .../_views/_config_groups_editor.py | 1 - .../_views/_config_presets_table.py | 17 +++++ test_column_stretching.py | 0 4 files changed, 66 insertions(+), 17 deletions(-) delete mode 100644 test_column_stretching.py diff --git a/src/pymmcore_widgets/_models/_config_group_pivot_model.py b/src/pymmcore_widgets/_models/_config_group_pivot_model.py index e1e26ac10..e63794e18 100644 --- a/src/pymmcore_widgets/_models/_config_group_pivot_model.py +++ b/src/pymmcore_widgets/_models/_config_group_pivot_model.py @@ -34,7 +34,7 @@ def setSourceModel(self, src_model: QConfigGroupsModel) -> None: src_model.modelReset.connect(self._rebuild) src_model.rowsInserted.connect(self._rebuild) src_model.rowsRemoved.connect(self._rebuild) - src_model.dataChanged.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.""" @@ -95,6 +95,35 @@ def setData( self._src.dataChanged.emit(preset_idx, preset_idx, [role]) return True + 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.""" + # Only rebuild if the change affects our currently displayed group + if self._gidx is None: + return + + # Check if any of the changed indices are within our current group + current_group_row = self._gidx.row() + top_left_parent = top_left.parent() + top_left_parent_row = top_left_parent.row() + + # Walk through all changed indices to see if any affect our group + for row in range(top_left.row(), bottom_right.row() + 1): + # If the change is at the root level (groups) + if not top_left_parent.isValid() and row == current_group_row: + # Our group's metadata changed (like channel group status) + # This doesn't affect the pivot table content, so no rebuild needed + return + + # If the change is within a group (presets or preset settings) + if top_left_parent_row == current_group_row: + # Changes within our current group - need to rebuild + self._rebuild() + # ---------------------------------------------------------------- build -- def _rebuild(self) -> None: # slot signature is flexible @@ -102,22 +131,26 @@ def _rebuild(self) -> None: # slot signature is flexible return # pragma: no cover self.beginResetModel() - 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._presets = [] + self._rows = [] 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 - - self.endResetModel() + 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 -- diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index 76c134b9c..aebd4ac51 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -237,7 +237,6 @@ def _on_group_changed(self, current: QModelIndex, previous: QModelIndex) -> None """Called when the group selection in the GroupPresetSelector changes.""" self._preset_table.setGroup(current) self._preset_table.view.stretchHeaders() - self._preset_table.view.openPersistentEditors() if current.isValid(): group = current.data(Qt.ItemDataRole.UserRole) 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 84b3c8380..68ae8b1de 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py @@ -57,6 +57,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): @@ -69,6 +74,11 @@ def stretchHeaders(self) -> None: hh.setSectionResizeMode(col, hh.ResizeMode.Stretch) self._have_stretched_headers = True + 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.""" @@ -98,6 +108,9 @@ 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.""" @@ -106,11 +119,15 @@ def transpose(self) -> None: 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.""" diff --git a/test_column_stretching.py b/test_column_stretching.py deleted file mode 100644 index e69de29bb..000000000 From 3148d4758ec4925da92a2c06f43cf44917661ac2 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 6 Jul 2025 12:36:04 -0400 Subject: [PATCH 59/70] remove example --- examples/flat.py | 124 ----------------------------------------------- 1 file changed, 124 deletions(-) delete mode 100644 examples/flat.py diff --git a/examples/flat.py b/examples/flat.py deleted file mode 100644 index bd532d376..000000000 --- a/examples/flat.py +++ /dev/null @@ -1,124 +0,0 @@ -from __future__ import annotations - -from PyQt6 import QtCore, QtGui, QtWidgets - -from pymmcore_widgets._models._tree_flattening import TreeFlatteningProxy - - -def build_tree_model() -> QtGui.QStandardItemModel: - """Create a simple 5-level tree: A-i / B-j / C-k / D-l / E-m.""" - model = QtGui.QStandardItemModel() - model.setHorizontalHeaderLabels(["Name"]) - - item_a0 = QtGui.QStandardItem("A0") - model.appendRow(item_a0) - item_a1 = QtGui.QStandardItem("A1") - model.appendRow(item_a1) - - item_b00 = QtGui.QStandardItem("B00") - item_a0.appendRow(item_b00) - item_b01 = QtGui.QStandardItem("B01") - item_a0.appendRow(item_b01) - - item_b10 = QtGui.QStandardItem("B10") - item_a1.appendRow(item_b10) - item_b11 = QtGui.QStandardItem("B11") - item_a1.appendRow(item_b11) - - item_c000 = QtGui.QStandardItem("C000") - item_b00.appendRow(item_c000) - item_c001 = QtGui.QStandardItem("C001") - item_b00.appendRow(item_c001) - - item_c111 = QtGui.QStandardItem("C111") - item_b11.appendRow(item_c111) - - return model - - -def print_model( - model: QtCore.QAbstractItemModel, - parent: QtCore.QModelIndex | None = None, - depth: int = 0, -) -> None: - """Print the model structure to the console.""" - if parent is None: - parent = QtCore.QModelIndex() - - rows = model.rowCount(parent) - for r in range(rows): - child = model.index(r, 0, parent) # tree is in column 0 - # print an ascii tree - print(" " * depth + f"- {model.data(child)}") - print_model(model, child, depth + 1) - - -class MainWindow(QtWidgets.QWidget): - """Main demo window.""" - - def __init__(self) -> None: - super().__init__() - self.setWindowTitle("Flatten proxy demo") - - # source tree - src_model = build_tree_model() - print_model(src_model) - - tree1 = QtWidgets.QTreeView() - tree1.setModel(src_model) - tree1.expandAll() - - # proxy + table view - self.proxy = TreeFlatteningProxy(row_depth=0) - self.proxy.setSourceModel(src_model) - - self.tree2 = tree2 = QtWidgets.QTreeView() - tree2.setAlternatingRowColors(True) - tree2.setSortingEnabled(True) - tree2.setModel(self.proxy) - tree1.expandAll() - - # depth selector - self.combo = depth_selector = QtWidgets.QComboBox() - depth_selector.addItems( - [ - "Rows = level A (depth 0)", - "Rows = level B (depth 1)", - "Rows = level C (depth 2)", - ] - ) - - depth_selector.currentIndexChanged.connect(self.proxy.set_row_depth) - - # layout - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(QtWidgets.QLabel("Source tree")) - layout.addWidget(tree1, 2) - layout.addWidget(QtWidgets.QLabel("Flattened table (sortable)")) - layout.addWidget(tree2, 3) - layout.addWidget(QtWidgets.QLabel("Choose row depth")) - layout.addWidget(depth_selector) - - -def main() -> None: - """Run the demo application.""" - import sys - - app = QtWidgets.QApplication(sys.argv) - win = MainWindow() - win.resize(800, 600) - win.show() - - # PROGRAMMATICALLY INTERACT HERE - - # Expand all in the tree view to show hierarchical structure - win.combo.setCurrentIndex(1) # Set to depth 1 to show the improved layout - win.tree2.expandAll() - - win.grab().save("flatten_proxy_demo.png", "PNG") - # sys.exit(app.processEvents()) - sys.exit(app.exec()) - - -if __name__ == "__main__": - main() From 388ff7dabe9dc277458becffffa3bc86a8bd84a6 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 6 Jul 2025 14:06:30 -0400 Subject: [PATCH 60/70] fix visibility --- .../_models/_q_device_prop_model.py | 5 +- .../_views/_checked_properties_proxy.py | 63 +++++++++++++++++++ .../_views/_device_property_selector.py | 45 +++++++++---- 3 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 src/pymmcore_widgets/config_presets/_views/_checked_properties_proxy.py diff --git a/src/pymmcore_widgets/_models/_q_device_prop_model.py b/src/pymmcore_widgets/_models/_q_device_prop_model.py index 34af4d054..06f4e2ddd 100644 --- a/src/pymmcore_widgets/_models/_q_device_prop_model.py +++ b/src/pymmcore_widgets/_models/_q_device_prop_model.py @@ -81,7 +81,6 @@ def _get_prop_data(self, prop: DevicePropertySetting, col: int, role: int) -> An 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) - index.column() if node is self._root: return None @@ -90,7 +89,7 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A if role == Qt.ItemDataRole.UserRole: return node.payload - elif role == Qt.ItemDataRole.CheckStateRole and col == 0: + elif role == Qt.ItemDataRole.CheckStateRole: if isinstance(setting := node.payload, DevicePropertySetting): return node.check_state @@ -114,7 +113,7 @@ def setData( 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 = value + node.check_state = Qt.CheckState(value) self.dataChanged.emit(index, index, [role]) return True diff --git a/src/pymmcore_widgets/config_presets/_views/_checked_properties_proxy.py b/src/pymmcore_widgets/config_presets/_views/_checked_properties_proxy.py new file mode 100644 index 000000000..b0197b3ae --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_views/_checked_properties_proxy.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import Any + +from qtpy.QtCore import QAbstractItemModel, QModelIndex, QSortFilterProxyModel, Qt + + +class CheckedProxy(QSortFilterProxyModel): + """Proxy model that keeps only rows with at least one *checked* item. + + If check_column is `-1`, all columns in the row are inspected, otherwise + only the specified column is checked. + + Parameters + ---------- + check_column + Column index on which to look for the check state. Use `-1` to + inspect *all* columns in the row. + include_partially_checked + If `True`, rows with `Qt.PartiallyChecked` will also be kept. + """ + + def __init__( + self, + check_column: int = -1, + *, + include_partially_checked: bool = False, + parent: Any | None = None, + ) -> None: + super().__init__(parent) + self._check_column = check_column + self._include_partial = include_partially_checked + + def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool: + src = self.sourceModel() + if src is None: + return False + + # 1. Check the current row + cols = ( + range(src.columnCount(source_parent)) + if self._check_column < 0 + else [self._check_column] + ) + for col in cols: + idx = src.index(source_row, col, source_parent) + state = idx.data(Qt.ItemDataRole.CheckStateRole) + if state == Qt.CheckState.Checked or ( + state == Qt.CheckState.PartiallyChecked and self._include_partial + ): + return True + # 2. If not checked, keep the row if *any* descendant is checked + child_parent = src.index(source_row, 0, source_parent) + for i in range(src.rowCount(child_parent)): + if self.filterAcceptsRow(i, child_parent): + return True + + return False + + def setSourceModel(self, model: QAbstractItemModel | None) -> None: + super().setSourceModel(model) + if model is not None: # refresh when check states change + model.dataChanged.connect(self.invalidate) diff --git a/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py index 687ba5e69..11316e3fc 100644 --- a/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py +++ b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py @@ -18,6 +18,7 @@ from pymmcore_widgets._models import Device, QDevicePropertyModel from pymmcore_widgets._models._q_device_prop_model import DevicePropertyFlatProxy +from ._checked_properties_proxy import CheckedProxy from ._device_type_filter_proxy import DeviceTypeFilter if TYPE_CHECKING: @@ -189,34 +190,48 @@ def __init__(self, parent: QWidget | None = None) -> None: self._model = QDevicePropertyModel() - self._filter_proxy = DeviceTypeFilter(allowed={DeviceType.Any}, parent=self) - self._filter_proxy.setSourceModel(self._model) + self._filtered_model = DeviceTypeFilter(allowed={DeviceType.Any}, parent=self) + self._filtered_model.setSourceModel(self._model) - self._flat_proxy = DevicePropertyFlatProxy() - self._flat_proxy.setSourceModel(self._filter_proxy) + self._flat_filtered_model = DevicePropertyFlatProxy() + self._flat_filtered_model.setSourceModel(self._filtered_model) + + _checked = CheckedProxy(check_column=1) + _checked.setSourceModel(self._model) + self._flat_checked_model = DevicePropertyFlatProxy() + self._flat_checked_model.setSourceModel(_checked) + + # Selected properties tree (shows only checked items) + self.selected_tree = QTreeView(self) + self.selected_tree.setModel(self._flat_checked_model) + self.selected_tree.setSortingEnabled(True) + self.selected_tree.setMaximumHeight(150) # Limit height self.tree = QTreeView(self) # Start with TableView (flat proxy model) - self.tree.setModel(self._flat_proxy) + self.tree.setModel(self._flat_filtered_model) self.tree.setSortingEnabled(True) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) + layout.addWidget(self.selected_tree) layout.addWidget(self._dev_type_btns) layout.addWidget(self._tb2) layout.addWidget(self.tree) self._dev_type_btns.checkedDevicesChanged.connect( - self._filter_proxy.setAllowedDeviceTypes + self._filtered_model.setAllowedDeviceTypes ) self._dev_type_btns.readOnlyToggled.connect( - self._filter_proxy.setReadOnlyVisible + self._filtered_model.setReadOnlyVisible + ) + self._dev_type_btns.preInitToggled.connect( + self._filtered_model.setPreInitVisible ) - self._dev_type_btns.preInitToggled.connect(self._filter_proxy.setPreInitVisible) self._tb2.expandAllToggled.connect(self._expand_all) self._tb2.collapseAllToggled.connect(self.tree.collapseAll) - self._tb2.filterStringChanged.connect(self._filter_proxy.setFilterFixedString) + self._tb2.filterStringChanged.connect(self._filtered_model.setFilterFixedString) self._tb2.viewModeToggled.connect(self._toggle_view_mode) def _expand_all(self) -> None: @@ -227,12 +242,12 @@ def _toggle_view_mode(self, is_tree_view: bool) -> None: """Toggle between TreeView and TableView modes.""" if is_tree_view: # Switch to TreeView: use filter proxy directly - self.tree.setModel(self._filter_proxy) + self.tree.setModel(self._filtered_model) self.tree.setColumnHidden(1, True) # Hide the second column (device type) self.tree.expandAll() else: # Switch to TableView: use flat proxy - self.tree.setModel(self._flat_proxy) + self.tree.setModel(self._flat_filtered_model) self.tree.setColumnHidden(1, False) # Show the second column (property) def clear(self) -> None: @@ -246,11 +261,15 @@ def setChecked(self, settings: Iterable[tuple[str, str, str]]) -> None: def setAvailableDevices(self, devices: Iterable[Device]) -> None: devices = list(devices) self._model.set_devices(devices) - # self.tree.setColumnHidden(1, True) # Hide the second column (device type) - # self.tree.setHeaderHidden(True) + + # Configure main tree view header if hh := self.tree.header(): hh.setSectionResizeMode(hh.ResizeMode.ResizeToContents) + # Configure selected properties tree view header + if hh := self.selected_tree.header(): + hh.setSectionResizeMode(hh.ResizeMode.ResizeToContents) + dev_types = {d.type for d in devices} self._dev_type_btns.setVisibleDeviceTypes(dev_types) From 7f55945bccc9ffd565747af95769456908cfa850 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 6 Jul 2025 15:09:12 -0400 Subject: [PATCH 61/70] refactor: update icon sizes and layout margins in configuration editors --- examples/config_groups_editor.py | 2 - .../_models/_q_config_model.py | 1 + .../_models/_q_device_prop_model.py | 181 +++++++++++------- .../_views/_config_groups_editor.py | 14 +- .../_views/_device_property_selector.py | 62 ++++-- 5 files changed, 167 insertions(+), 93 deletions(-) diff --git a/examples/config_groups_editor.py b/examples/config_groups_editor.py index 4e72108b7..7b947c662 100644 --- a/examples/config_groups_editor.py +++ b/examples/config_groups_editor.py @@ -10,8 +10,6 @@ core.loadSystemConfiguration() cfg = ConfigGroupsEditor.create_from_core(core) -# cfg.setCurrentPreset("Channel", "FITC") cfg.show() -cfg.resize(1200, 800) app.exec() diff --git a/src/pymmcore_widgets/_models/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py index 07bcf8d7a..fb354f597 100644 --- a/src/pymmcore_widgets/_models/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -352,6 +352,7 @@ def remove( "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 diff --git a/src/pymmcore_widgets/_models/_q_device_prop_model.py b/src/pymmcore_widgets/_models/_q_device_prop_model.py index 06f4e2ddd..bc37683ee 100644 --- a/src/pymmcore_widgets/_models/_q_device_prop_model.py +++ b/src/pymmcore_widgets/_models/_q_device_prop_model.py @@ -172,6 +172,7 @@ def setSourceModel(self, source_model: QAbstractItemModel | None) -> None: 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 @@ -181,25 +182,10 @@ def setSourceModel(self, source_model: QAbstractItemModel | None) -> None: 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 _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 sourceModel(self) -> QAbstractItemModel | None: """Return the source model.""" return self._source_model @@ -225,26 +211,11 @@ 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() or not self._source_model: - return None - - row = index.row() - if row >= len(self._rows): + if not index.isValid(): return None - drow, prow = self._rows[row] - col = index.column() - - if col == 0: # Device column - device_idx = self._source_model.index(drow, 0) - return self._source_model.data(device_idx, role) - elif col == 1: # Property column - device_idx = self._source_model.index(drow, 0) - if device_idx.isValid(): - prop_idx = self._source_model.index(prow, 0, device_idx) - return self._source_model.data(prop_idx, role) - - 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 @@ -252,46 +223,23 @@ def setData( if not index.isValid() or not self._source_model: return False - row = index.row() - if row >= len(self._rows): - return False - - drow, prow = self._rows[row] - col = index.column() - - if col == 0: - device_idx = self._source_model.index(drow, 0) - return bool(self._source_model.setData(device_idx, value, role)) - elif col == 1: - device_idx = self._source_model.index(drow, 0) - if device_idx.isValid(): - prop_idx = self._source_model.index(prow, 0, device_idx) - return bool(self._source_model.setData(prop_idx, value, role)) + 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 - row = index.row() - if row >= len(self._rows): + source_idx = self._mapped_index(index.row(), index.column()) + if not source_idx.isValid(): return Qt.ItemFlag.NoItemFlags - drow, prow = self._rows[row] - col = index.column() - - if col == 0: # Device column - device_idx = self._source_model.index(drow, 0) - return self._source_model.flags(device_idx) - elif col == 1: # Property column - device_idx = self._source_model.index(drow, 0) - if device_idx.isValid(): - prop_idx = self._source_model.index(prow, 0, device_idx) - return ( - self._source_model.flags(prop_idx) | Qt.ItemFlag.ItemIsUserCheckable - ) - - return Qt.ItemFlag.NoItemFlags + flags = self._source_model.flags(source_idx) + if index.column() == 1: + flags |= Qt.ItemFlag.ItemIsUserCheckable + return flags def headerData( self, @@ -324,3 +272,104 @@ def _key(x: tuple[int, int]) -> Any: 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/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index aebd4ac51..c6056c0fd 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -52,7 +52,7 @@ def __init__(self, parent: ConfigGroupsEditor) -> None: super().__init__(parent) # tool bar -------------------------------------------------------------- - self.setIconSize(QSize(22, 22)) + self.setIconSize(QSize(20, 20)) self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) # Create exclusive action group for view modes @@ -185,6 +185,8 @@ def update_from_core( def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) + self.setStyleSheet("QToolBar { border: none; };") + self._model = QConfigGroupsModel() # widgets ------------------------------------------------------------- @@ -217,10 +219,10 @@ def __init__(self, parent: QWidget | None = None) -> None: self._current_mode: LayoutMode = LayoutMode.FAVOR_PRESETS layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) + layout.setContentsMargins(8, 8, 8, 8) layout.setSpacing(0) layout.addWidget(self._tb) - self.setLayoutMode(mode=LayoutMode.FAVOR_PRESETS) + self.setLayoutMode(mode=LayoutMode.FAVOR_PROPERTIES) # signals ------------------------------------------------------------ @@ -339,11 +341,15 @@ def _build_layout(self, mode: LayoutMode) -> QSplitter: main = QSplitter(Qt.Orientation.Horizontal) main.addWidget(left_splitter) main.addWidget(prop_sel) - # main.setStretchFactor(1, 1) + main.setSizes([800, 420]) return main raise ValueError(f"Unknown layout mode: {mode}") + def sizeHint(self) -> QSize: + """Suggest a size for the widget.""" + return QSize(1200, 800) + def setLayoutMode(self, mode: LayoutMode | None = None) -> None: """Slot connected to the toolbar action.""" if not (layout := self.layout()): diff --git a/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py index 11316e3fc..94db47add 100644 --- a/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py +++ b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, cast from pymmcore_plus import DeviceType -from qtpy.QtCore import QModelIndex, QSize +from qtpy.QtCore import QAbstractItemModel, QModelIndex, QSize from qtpy.QtWidgets import ( QLineEdit, QSizePolicy, @@ -37,6 +37,8 @@ class _DeviceButtonToolbar(QToolBar): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) + self.setIconSize(QSize(16, 16)) + for device_type, icon_key in sorted( DEVICE_TYPE_ICON.items(), key=lambda x: x[0].name ): @@ -118,6 +120,7 @@ class _PropertySearchToolbar(QToolBar): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) + self.setIconSize(QSize(16, 16)) # Add toggle button for view mode self.act_toggle_view = cast( @@ -182,11 +185,10 @@ class DevicePropertySelector(QWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) - self._dev_type_btns = _DeviceButtonToolbar(self) - self._dev_type_btns.setIconSize(QSize(16, 16)) + # WIDGETS ------------------------ + + self._dev_type_btns = dev_btns = _DeviceButtonToolbar(self) self._tb2 = _PropertySearchToolbar(self) - self._tb2.setIconSize(QSize(16, 16)) - self.setStyleSheet("QToolBar { border: none; };") self._model = QDevicePropertyModel() @@ -202,10 +204,8 @@ def __init__(self, parent: QWidget | None = None) -> None: self._flat_checked_model.setSourceModel(_checked) # Selected properties tree (shows only checked items) - self.selected_tree = QTreeView(self) + self.selected_tree = _ShrinkingQTreeView(self) self.selected_tree.setModel(self._flat_checked_model) - self.selected_tree.setSortingEnabled(True) - self.selected_tree.setMaximumHeight(150) # Limit height self.tree = QTreeView(self) # Start with TableView (flat proxy model) @@ -216,19 +216,15 @@ def __init__(self, parent: QWidget | None = None) -> None: layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self.selected_tree) - layout.addWidget(self._dev_type_btns) + layout.addWidget(dev_btns) layout.addWidget(self._tb2) - layout.addWidget(self.tree) + layout.addWidget(self.tree, 2) - self._dev_type_btns.checkedDevicesChanged.connect( + dev_btns.checkedDevicesChanged.connect( self._filtered_model.setAllowedDeviceTypes ) - self._dev_type_btns.readOnlyToggled.connect( - self._filtered_model.setReadOnlyVisible - ) - self._dev_type_btns.preInitToggled.connect( - self._filtered_model.setPreInitVisible - ) + dev_btns.readOnlyToggled.connect(self._filtered_model.setReadOnlyVisible) + dev_btns.preInitToggled.connect(self._filtered_model.setPreInitVisible) self._tb2.expandAllToggled.connect(self._expand_all) self._tb2.collapseAllToggled.connect(self.tree.collapseAll) self._tb2.filterStringChanged.connect(self._filtered_model.setFilterFixedString) @@ -265,10 +261,7 @@ def setAvailableDevices(self, devices: Iterable[Device]) -> None: # Configure main tree view header if hh := self.tree.header(): hh.setSectionResizeMode(hh.ResizeMode.ResizeToContents) - - # Configure selected properties tree view header - if hh := self.selected_tree.header(): - hh.setSectionResizeMode(hh.ResizeMode.ResizeToContents) + self.selected_tree.setColumnWidth(0, self.tree.columnWidth(0)) dev_types = {d.type for d in devices} self._dev_type_btns.setVisibleDeviceTypes(dev_types) @@ -278,3 +271,30 @@ def setAvailableDevices(self, devices: Iterable[Device]) -> None: # {DeviceType.AutoFocus, DeviceType.Core, DeviceType.Camera} # ) # self._device_type_buttons.setCheckedDeviceTypes(dev_types) + + +class _ShrinkingQTreeView(QTreeView): + """A QTreeView that shrinks to fit its contents.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum) + self.updateGeometry() + self.setHeaderHidden(True) + self.setRootIsDecorated(False) + + def sizeHint(self) -> QSize: + """Return the size hint based on the contents.""" + size = super().sizeHint() + size.setHeight(50) + if (model := self.model()) and (nrows := model.rowCount()) > 0: + size.setHeight(self.sizeHintForRow(0) * nrows + 2 * self.frameWidth() + 20) + return size.boundedTo(QSize(10000, 220)) + + def setModel(self, model: QAbstractItemModel | None) -> None: + """Set the model and connect signals to update geometry.""" + super().setModel(model) + if model is not None: + model.modelReset.connect(self.updateGeometry) + model.rowsInserted.connect(self.updateGeometry) + model.rowsRemoved.connect(self.updateGeometry) From 9f03dc854c68f08984e04dd927bf2bdb2e3d45b5 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 6 Jul 2025 16:16:12 -0400 Subject: [PATCH 62/70] feat: add preset property update functionality and enhance device property selector --- examples/config_groups_editor.py | 1 + .../_models/_q_config_model.py | 30 +++ .../config_presets/_config_groups_editor.py | 0 .../_views/_config_groups_editor.py | 221 ++++++++++-------- .../_views/_device_property_selector.py | 118 +++++++++- 5 files changed, 262 insertions(+), 108 deletions(-) delete mode 100644 src/pymmcore_widgets/config_presets/_config_groups_editor.py diff --git a/examples/config_groups_editor.py b/examples/config_groups_editor.py index 7b947c662..8afcedeb4 100644 --- a/examples/config_groups_editor.py +++ b/examples/config_groups_editor.py @@ -10,6 +10,7 @@ core.loadSystemConfiguration() cfg = ConfigGroupsEditor.create_from_core(core) +cfg.setCurrentPreset("Channel", "FITC") cfg.show() app.exec() diff --git a/src/pymmcore_widgets/_models/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py index fb354f597..b3789677c 100644 --- a/src/pymmcore_widgets/_models/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -384,6 +384,36 @@ 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): + raise ValueError("Reference index is not a ConfigPreset.") + + 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 diff --git a/src/pymmcore_widgets/config_presets/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_config_groups_editor.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index c6056c0fd..fc40ee6c2 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -4,7 +4,7 @@ from enum import Enum, auto from typing import TYPE_CHECKING, cast -from qtpy.QtCore import QModelIndex, QSize, Qt, Signal +from qtpy.QtCore import QModelIndex, QSignalBlocker, QSize, Qt, Signal from qtpy.QtWidgets import ( QGroupBox, QSizePolicy, @@ -22,6 +22,9 @@ get_config_groups, get_loaded_devices, ) +from pymmcore_widgets._models._py_config_model import ( + ConfigPreset, +) from ._config_presets_table import ConfigPresetsTable from ._device_property_selector import DevicePropertySelector @@ -47,91 +50,6 @@ class LayoutMode(Enum): # ----------------------------------------------------------------------------- -class _ConfigEditorToolbar(QToolBar): - def __init__(self, parent: ConfigGroupsEditor) -> None: - super().__init__(parent) - # tool bar -------------------------------------------------------------- - - self.setIconSize(QSize(20, 20)) - self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) - - # Create exclusive action group for view modes - view_action_group = QActionGroup(self) - - # Column View action - column_icon = QIconifyIcon("fluent:layout-column-two-24-regular") - if column_act := self.addAction( - column_icon, "Column View", parent._group_preset_sel.showColumnView - ): - column_act.setCheckable(True) - column_act.setChecked(True) - view_action_group.addAction(column_act) - - # Tree View action - if tree_act := self.addAction( - StandardIcon.TREE.icon(), "Tree View", parent._group_preset_sel.showTreeView - ): - tree_act.setCheckable(True) - view_action_group.addAction(tree_act) - - self.addAction( - StandardIcon.FOLDER_ADD.icon(), - "Add Group", - parent._model.add_group, - ) - self.addAction( - StandardIcon.DOCUMENT_ADD.icon(), - "Add Preset", - parent._add_preset_to_current_group, - ) - self.addAction( - StandardIcon.DELETE.icon(), - "Remove", - parent._group_preset_sel.removeSelected, - ) - self.addAction( - StandardIcon.COPY.icon(), - "Duplicate", - parent._group_preset_sel.duplicateSelected, - ) - self.addSeparator() - self.set_channel_action = cast( - "QAction", - self.addAction( - StandardIcon.CHANNEL_GROUP.icon(), - "Set Channel Group", - ), - ) - - @self.set_channel_action.triggered.connect # type: ignore[misc] - def _on_set_channel_group() -> None: - parent._group_preset_sel.setCurrentGroupAsChannelGroup() - self.set_channel_action.setEnabled(False) - - spacer = QWidget(self) - spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - self.addWidget(spacer) - - if act := self.addAction( - StandardIcon.HELP.icon(), - "Help", - parent._show_help, - ): - act.setToolTip("Show help") - - icon = QIconifyIcon( - "fluent:layout-row-two-split-top-focus-bottom-16-filled", color="#666" - ) - icon.addKey( - "fluent:layout-column-two-split-left-focus-right-16-filled", - state=QIconifyIcon.State.On, - color="#666", - ) - if act := self.addAction(icon, "Layout", parent.setLayoutMode): - act.setToolTip("Toggle layout mode") - act.setCheckable(True) - - class ConfigGroupsEditor(QWidget): """Widget composed of two QListViews backed by a single tree model. @@ -217,7 +135,6 @@ def __init__(self, parent: QWidget | None = None) -> None: # layout ------------------------------------------------------------ - self._current_mode: LayoutMode = LayoutMode.FAVOR_PRESETS layout = QVBoxLayout(self) layout.setContentsMargins(8, 8, 8, 8) layout.setSpacing(0) @@ -228,6 +145,9 @@ def __init__(self, parent: QWidget | None = None) -> None: self._group_preset_sel.currentGroupChanged.connect(self._on_group_changed) self._group_preset_sel.currentPresetChanged.connect(self._on_preset_changed) + self._prop_selector.checkedPropertiesChanged.connect( + self._on_prop_selection_changed + ) # self._group_preset_stack.presetSelectionChanged.connect(self._on_preset_sel) # self._model.dataChanged.connect(self._on_model_data_changed) # self._props.valueChanged.connect(self._on_prop_table_changed) @@ -237,23 +157,44 @@ def __init__(self, parent: QWidget | None = None) -> None: # ------------------------------------------------------------------ def _on_group_changed(self, current: QModelIndex, previous: QModelIndex) -> None: """Called when the group selection in the GroupPresetSelector changes.""" + # Show this group in the preset table self._preset_table.setGroup(current) self._preset_table.view.stretchHeaders() - if current.isValid(): - group = current.data(Qt.ItemDataRole.UserRole) - if isinstance(group, ConfigGroup) and group.is_channel_group: - self._tb.set_channel_action.setEnabled(False) - else: - self._tb.set_channel_action.setEnabled(True) + # Enable/disable "set channel group" action depending on whether the selected + # group is already a channel group + group = current.data(Qt.ItemDataRole.UserRole) + if isinstance(group, ConfigGroup): + self._tb.set_channel_action.setEnabled(not group.is_channel_group) def _on_preset_changed(self, current: QModelIndex, previous: QModelIndex) -> None: """Called when the preset selection in the GroupPresetSelector changes.""" if not current.isValid(): + with QSignalBlocker(self._prop_selector): + self._prop_selector.setCheckedProperties([]) return - view = self._preset_table.view + + # highlight the selected preset in the table + table = self._preset_table.view row = current.row() - view.selectRow(row) if view.isTransposed() else view.selectColumn(row) + table.selectRow(row) if table.isTransposed() else table.selectColumn(row) + + # update the selected properties in the property selector + preset = current.data(Qt.ItemDataRole.UserRole) + if isinstance(preset, ConfigPreset): + with QSignalBlocker(self._prop_selector): + self._prop_selector.setCheckedProperties(preset.settings) + + def _on_prop_selection_changed(self, props: Sequence[tuple[str, str]]) -> None: + """Called when the selection in the DevicePropertySelector changes. + + value is a tuple of (device_label, property_name) pairs. + We need to update the device properties in the currently selected preset. + to match + """ + idx = self._group_preset_sel.currentPreset() + if idx.isValid(): + self._model.update_preset_properties(idx, props) def setCurrentGroup(self, group: str) -> None: """Set the currently selected group in the editor.""" @@ -363,6 +304,8 @@ def setLayoutMode(self, mode: LayoutMode | None = None) -> None: else: mode = LayoutMode(mode) + self._tb.toggle_layout_action.setChecked(mode == LayoutMode.FAVOR_PROPERTIES) + sizes = None with _updates_disabled(self): if isinstance( @@ -375,7 +318,6 @@ def setLayoutMode(self, mode: LayoutMode | None = None) -> None: # build and insert the replacement self._main_splitter = new_splitter = self._build_layout(mode) - self._current_mode = mode layout.addWidget(new_splitter) if sizes is not None: @@ -469,6 +411,93 @@ def _show_help(self) -> None: # return preset +class _ConfigEditorToolbar(QToolBar): + def __init__(self, parent: ConfigGroupsEditor) -> None: + super().__init__(parent) + # tool bar -------------------------------------------------------------- + + self.setIconSize(QSize(20, 20)) + self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + + # Create exclusive action group for view modes + view_action_group = QActionGroup(self) + + # Column View action + column_icon = QIconifyIcon("fluent:layout-column-two-24-regular") + if column_act := self.addAction( + column_icon, "Column View", parent._group_preset_sel.showColumnView + ): + column_act.setCheckable(True) + column_act.setChecked(True) + view_action_group.addAction(column_act) + + # Tree View action + if tree_act := self.addAction( + StandardIcon.TREE.icon(), "Tree View", parent._group_preset_sel.showTreeView + ): + tree_act.setCheckable(True) + view_action_group.addAction(tree_act) + + self.addAction( + StandardIcon.FOLDER_ADD.icon(), + "Add Group", + parent._model.add_group, + ) + self.addAction( + StandardIcon.DOCUMENT_ADD.icon(), + "Add Preset", + parent._add_preset_to_current_group, + ) + self.addAction( + StandardIcon.DELETE.icon(), + "Remove", + parent._group_preset_sel.removeSelected, + ) + self.addAction( + StandardIcon.COPY.icon(), + "Duplicate", + parent._group_preset_sel.duplicateSelected, + ) + self.addSeparator() + self.set_channel_action = cast( + "QAction", + self.addAction( + StandardIcon.CHANNEL_GROUP.icon(), + "Set Channel Group", + ), + ) + + @self.set_channel_action.triggered.connect # type: ignore[misc] + def _on_set_channel_group() -> None: + parent._group_preset_sel.setCurrentGroupAsChannelGroup() + self.set_channel_action.setEnabled(False) + + spacer = QWidget(self) + spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.addWidget(spacer) + + if act := self.addAction( + StandardIcon.HELP.icon(), + "Help", + parent._show_help, + ): + act.setToolTip("Show help") + + icon = QIconifyIcon( + "fluent:layout-row-two-split-top-focus-bottom-16-filled", color="#666" + ) + icon.addKey( + "fluent:layout-column-two-split-left-focus-right-16-filled", + state=QIconifyIcon.State.On, + color="#666", + ) + self.toggle_layout_action = cast( + "QAction", self.addAction(icon, "Layout", parent.setLayoutMode) + ) + self.toggle_layout_action.setToolTip("Toggle layout mode") + self.toggle_layout_action.setCheckable(True) + + @contextmanager def _updates_disabled(widget: QWidget) -> Iterator[None]: """Context manager to temporarily disable updates for a widget.""" diff --git a/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py index 94db47add..3fd8bf42f 100644 --- a/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py +++ b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py @@ -1,9 +1,10 @@ from __future__ import annotations +from collections import defaultdict from typing import TYPE_CHECKING, cast from pymmcore_plus import DeviceType -from qtpy.QtCore import QAbstractItemModel, QModelIndex, QSize +from qtpy.QtCore import QAbstractItemModel, QModelIndex, QSize, Qt from qtpy.QtWidgets import ( QLineEdit, QSizePolicy, @@ -16,6 +17,7 @@ from pymmcore_widgets._icons import DEVICE_TYPE_ICON, StandardIcon from pymmcore_widgets._models import Device, QDevicePropertyModel +from pymmcore_widgets._models._py_config_model import DevicePropertySetting from pymmcore_widgets._models._q_device_prop_model import DevicePropertyFlatProxy from ._checked_properties_proxy import CheckedProxy @@ -182,6 +184,8 @@ def _toggle_view_mode(self) -> None: class DevicePropertySelector(QWidget): + checkedPropertiesChanged = Signal(tuple) # tuple[DevicePropertySetting, ...] + def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) @@ -191,6 +195,10 @@ def __init__(self, parent: QWidget | None = None) -> None: self._tb2 = _PropertySearchToolbar(self) self._model = QDevicePropertyModel() + self._model.dataChanged.connect(self._on_model_data_changed) + + # Track currently-checked (device_label, property_name) pairs + self._checked_props: set[tuple[str, str]] = set() self._filtered_model = DeviceTypeFilter(allowed={DeviceType.Any}, parent=self) self._filtered_model.setSourceModel(self._model) @@ -208,17 +216,15 @@ def __init__(self, parent: QWidget | None = None) -> None: self.selected_tree.setModel(self._flat_checked_model) self.tree = QTreeView(self) - # Start with TableView (flat proxy model) - self.tree.setModel(self._flat_filtered_model) - self.tree.setSortingEnabled(True) + self._toggle_view_mode(False) # Start with TableView (flat proxy model) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - layout.addWidget(self.selected_tree) layout.addWidget(dev_btns) layout.addWidget(self._tb2) layout.addWidget(self.tree, 2) + layout.addWidget(self.selected_tree) dev_btns.checkedDevicesChanged.connect( self._filtered_model.setAllowedDeviceTypes @@ -234,6 +240,50 @@ def _expand_all(self) -> None: """Expand all items in the tree view.""" self.tree.expandRecursively(QModelIndex()) + def _on_model_data_changed( + self, topLeft: QModelIndex, bottomRight: QModelIndex, roles: list[int] + ) -> None: + """Incrementally update the checke-property cache and emit when it changes.""" + if Qt.ItemDataRole.CheckStateRole not in roles: + return + + changed = False + + # Helper to update the cache for a single index + def _update(idx: QModelIndex) -> None: + nonlocal changed + prop = idx.data(Qt.ItemDataRole.UserRole) + if not isinstance(prop, DevicePropertySetting): + return + is_checked = ( + self._model.data(idx, Qt.ItemDataRole.CheckStateRole) + == Qt.CheckState.Checked + ) + key = prop.key() + if is_checked and key not in self._checked_props: + self._checked_props.add(key) + changed = True + elif not is_checked and key in self._checked_props: + self._checked_props.remove(key) + changed = True + + # Iterate through all rows in the changed range + parent = topLeft.parent() + for row in range(topLeft.row(), bottomRight.row() + 1): + idx = self._model.index(row, 0, parent) + if not idx.isValid(): + continue + _update(idx) + # If idx is a device row, also inspect its children + if not isinstance( + idx.data(Qt.ItemDataRole.UserRole), DevicePropertySetting + ): + for child_row in range(self._model.rowCount(idx)): + _update(self._model.index(child_row, 0, idx)) + + if changed: + self.checkedPropertiesChanged.emit(tuple(self._checked_props)) + def _toggle_view_mode(self, is_tree_view: bool) -> None: """Toggle between TreeView and TableView modes.""" if is_tree_view: @@ -241,18 +291,62 @@ def _toggle_view_mode(self, is_tree_view: bool) -> None: self.tree.setModel(self._filtered_model) self.tree.setColumnHidden(1, True) # Hide the second column (device type) self.tree.expandAll() + self.tree.setRootIsDecorated(True) + self.tree.setSortingEnabled(False) else: # Switch to TableView: use flat proxy self.tree.setModel(self._flat_filtered_model) self.tree.setColumnHidden(1, False) # Show the second column (property) + self.tree.setRootIsDecorated(False) + self.tree.setSortingEnabled(True) def clear(self) -> None: """Clear the current selection.""" # self.table.setValue([]) - def setChecked(self, settings: Iterable[tuple[str, str, str]]) -> None: + def clearCheckedProperties(self) -> None: + """Clear all checked properties.""" + # clear all checks + for row in range(self._model.rowCount()): + dev_idx = self._model.index(row, 0) + for prop_row in range(self._model.rowCount(dev_idx)): + prop_idx = self._model.index(prop_row, 0, dev_idx) + self._model.setData( + prop_idx, + Qt.CheckState.Unchecked, + Qt.ItemDataRole.CheckStateRole, + ) + self._checked_props.clear() + self.checkedPropertiesChanged.emit(()) + return + + def setCheckedProperties(self, props: Iterable[DevicePropertySetting]) -> None: """Set the checked state of the properties based on the given settings.""" - # self.table.setValue(settings) + self.clearCheckedProperties() + props = list(props) + + to_check = defaultdict(set) + for prop in props: + to_check[prop.device_label].add(prop.property_name) + + for row in range(self._model.rowCount()): + dev_idx = self._model.index(row, 0) + dev = dev_idx.data(Qt.ItemDataRole.UserRole) + if isinstance(dev, Device) and dev.label in to_check: + for prop_row in range(self._model.rowCount(dev_idx)): + prop_idx = self._model.index(prop_row, 0, dev_idx) + prop = prop_idx.data(Qt.ItemDataRole.UserRole) + if ( + isinstance(prop, DevicePropertySetting) + and prop.property_name in to_check[dev.label] + ): + self._model.setData( + prop_idx, + Qt.CheckState.Checked, + Qt.ItemDataRole.CheckStateRole, + ) + self._checked_props = {p.key() for p in props} + self.checkedPropertiesChanged.emit(tuple(self._checked_props)) def setAvailableDevices(self, devices: Iterable[Device]) -> None: devices = list(devices) @@ -266,11 +360,11 @@ def setAvailableDevices(self, devices: Iterable[Device]) -> None: dev_types = {d.type for d in devices} self._dev_type_btns.setVisibleDeviceTypes(dev_types) - # # hide some types that are often not immediately useful in this context - # dev_types.difference_update( - # {DeviceType.AutoFocus, DeviceType.Core, DeviceType.Camera} - # ) - # self._device_type_buttons.setCheckedDeviceTypes(dev_types) + # hide some types that are often not immediately useful in this context + dev_types.difference_update( + {DeviceType.AutoFocus, DeviceType.Core, DeviceType.Camera} + ) + self._dev_type_btns.setCheckedDeviceTypes(dev_types) class _ShrinkingQTreeView(QTreeView): From 119e005cab210f1d6c8c1f9ac188dc3a4c693a3a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 6 Jul 2025 16:43:52 -0400 Subject: [PATCH 63/70] refactor: streamline data change handling and improve rebuild logic in ConfigGroupPivotModel --- .../_models/_config_group_pivot_model.py | 80 ++++++++++++------- 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/src/pymmcore_widgets/_models/_config_group_pivot_model.py b/src/pymmcore_widgets/_models/_config_group_pivot_model.py index e63794e18..08b8a4507 100644 --- a/src/pymmcore_widgets/_models/_config_group_pivot_model.py +++ b/src/pymmcore_widgets/_models/_config_group_pivot_model.py @@ -95,35 +95,6 @@ def setData( self._src.dataChanged.emit(preset_idx, preset_idx, [role]) return True - 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.""" - # Only rebuild if the change affects our currently displayed group - if self._gidx is None: - return - - # Check if any of the changed indices are within our current group - current_group_row = self._gidx.row() - top_left_parent = top_left.parent() - top_left_parent_row = top_left_parent.row() - - # Walk through all changed indices to see if any affect our group - for row in range(top_left.row(), bottom_right.row() + 1): - # If the change is at the root level (groups) - if not top_left_parent.isValid() and row == current_group_row: - # Our group's metadata changed (like channel group status) - # This doesn't affect the pivot table content, so no rebuild needed - return - - # If the change is within a group (presets or preset settings) - if top_left_parent_row == current_group_row: - # Changes within our current group - need to rebuild - self._rebuild() - # ---------------------------------------------------------------- build -- def _rebuild(self) -> None: # slot signature is flexible @@ -222,3 +193,54 @@ def get_source_index_for_column(self, column: int) -> QModelIndex: 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 From cbafdd4329a2591f5769b885dfa26176d7bd324d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 6 Jul 2025 17:11:25 -0400 Subject: [PATCH 64/70] feat: add system group and startup/shutdown preset icons; enhance property handling in models --- src/pymmcore_widgets/_icons.py | 5 +- .../_models/_py_config_model.py | 23 +++++++ .../_models/_q_config_model.py | 16 ++++- .../_views/_config_groups_editor.py | 4 +- .../_views/_device_property_selector.py | 63 +++++++++++++++++-- .../device_properties/_property_widget.py | 4 +- 6 files changed, 103 insertions(+), 12 deletions(-) diff --git a/src/pymmcore_widgets/_icons.py b/src/pymmcore_widgets/_icons.py index ea4f112b6..43d37deed 100644 --- a/src/pymmcore_widgets/_icons.py +++ b/src/pymmcore_widgets/_icons.py @@ -42,8 +42,11 @@ class StandardIcon(str, Enum): 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" - def icon(self, color: str = "#333") -> QIconifyIcon: + def icon(self, color: str = "gray") -> QIconifyIcon: return QIconifyIcon(self.value, color=color) def __str__(self) -> str: diff --git a/src/pymmcore_widgets/_models/_py_config_model.py b/src/pymmcore_widgets/_models/_py_config_model.py index 37205917c..7743e71b2 100644 --- a/src/pymmcore_widgets/_models/_py_config_model.py +++ b/src/pymmcore_widgets/_models/_py_config_model.py @@ -163,6 +163,24 @@ 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.""" @@ -172,6 +190,11 @@ class ConfigGroup(_BaseModel): 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.""" diff --git a/src/pymmcore_widgets/_models/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py index b3789677c..73b9a9fc4 100644 --- a/src/pymmcore_widgets/_models/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -77,8 +77,15 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A 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: + 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("DevicePropertySetting", node.payload) @@ -154,6 +161,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: @@ -238,7 +248,7 @@ def add_preset( raise ValueError("Reference index is not a ConfigGroup.") name = self._unique_child_name(group_node, base_name, suffix="") - preset = ConfigPreset(name=name) + 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) @@ -483,10 +493,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." diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index fc40ee6c2..cda447037 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -165,7 +165,9 @@ def _on_group_changed(self, current: QModelIndex, previous: QModelIndex) -> None # group is already a channel group group = current.data(Qt.ItemDataRole.UserRole) if isinstance(group, ConfigGroup): - self._tb.set_channel_action.setEnabled(not group.is_channel_group) + self._tb.set_channel_action.setEnabled( + not group.is_channel_group and not group.is_system_group + ) def _on_preset_changed(self, current: QModelIndex, previous: QModelIndex) -> None: """Called when the preset selection in the GroupPresetSelector changes.""" diff --git a/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py index 3fd8bf42f..aaaaa7f48 100644 --- a/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py +++ b/src/pymmcore_widgets/config_presets/_views/_device_property_selector.py @@ -1,10 +1,10 @@ from __future__ import annotations from collections import defaultdict -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, ClassVar, cast from pymmcore_plus import DeviceType -from qtpy.QtCore import QAbstractItemModel, QModelIndex, QSize, Qt +from qtpy.QtCore import QAbstractItemModel, QAbstractProxyModel, QModelIndex, QSize, Qt from qtpy.QtWidgets import ( QLineEdit, QSizePolicy, @@ -27,7 +27,7 @@ from collections.abc import Iterable from PyQt6.QtCore import pyqtSignal as Signal - from PyQt6.QtGui import QAction + from PyQt6.QtGui import QAction, QKeyEvent else: from qtpy.QtCore import QModelIndex, Signal @@ -215,7 +215,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self.selected_tree = _ShrinkingQTreeView(self) self.selected_tree.setModel(self._flat_checked_model) - self.tree = QTreeView(self) + self.tree = _CheckableTreeView(self) self._toggle_view_mode(False) # Start with TableView (flat proxy model) layout = QVBoxLayout(self) @@ -367,9 +367,62 @@ def setAvailableDevices(self, devices: Iterable[Device]) -> None: self._dev_type_btns.setCheckedDeviceTypes(dev_types) -class _ShrinkingQTreeView(QTreeView): +class _CheckableTreeView(QTreeView): + """A QTreeView that allows toggling check state with Return/Enter key.""" + + ACTION_KEYS: ClassVar[set[int]] = {Qt.Key.Key_Return, Qt.Key.Key_Enter} + + def keyPressEvent(self, event: QKeyEvent) -> None: + """Handle key press events, specifically Return/Enter to toggle check state.""" + if event.key() in self.ACTION_KEYS: + current_index = self.currentIndex() + if current_index.isValid(): + self._toggle_check_state(current_index) + return + super().keyPressEvent(event) + + def _toggle_check_state(self, index: QModelIndex) -> None: + """Toggle the check state of the given index if it's a checkable property.""" + if not index.isValid(): + return + + # Get the data to check if this is a property item (not a device header) + user_data = index.data(Qt.ItemDataRole.UserRole) + if not isinstance(user_data, DevicePropertySetting): + return + + # Get current check state + current_state = index.data(Qt.ItemDataRole.CheckStateRole) + if current_state is None: + return + + # Toggle the state + new_state = ( + Qt.CheckState.Unchecked + if current_state == Qt.CheckState.Checked + else Qt.CheckState.Checked + ) + + # If we're working with a proxy model, we need to map to the source model + model = index.model() + if model is not None: + # Try to get the source model if this is a proxy + source_index = index + if isinstance(model, QAbstractProxyModel): + source_index = model.mapToSource(source_index) + model = model.sourceModel() + if model is None: + return + + # Set the new state on the source model + model.setData(source_index, new_state, Qt.ItemDataRole.CheckStateRole) + + +class _ShrinkingQTreeView(_CheckableTreeView): """A QTreeView that shrinks to fit its contents.""" + ACTION_KEYS: ClassVar[set[int]] = {Qt.Key.Key_Backspace} + def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum) diff --git a/src/pymmcore_widgets/device_properties/_property_widget.py b/src/pymmcore_widgets/device_properties/_property_widget.py index cfbc37621..db1ee54c7 100644 --- a/src/pymmcore_widgets/device_properties/_property_widget.py +++ b/src/pymmcore_widgets/device_properties/_property_widget.py @@ -77,7 +77,7 @@ def setValue(self, v: Any) -> None: if dec > self.decimals(): self.setDecimals(dec) return super().setValue( # type: ignore [no-any-return] - _stretch_range_to_contain(self, float(v)) + _stretch_range_to_contain(self, float(v or 0)) ) @@ -123,7 +123,7 @@ def value(self) -> int: def setValue(self, val: str | int) -> None: """Set value.""" - return self.setChecked(bool(int(val))) # type: ignore [no-any-return] + return self.setChecked(bool(int(val or 0))) # type: ignore [no-any-return] class ChoiceWidget(QComboBox): From 2f0fbce3b53d63248ab38f35d0bef901bb186ca1 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 6 Jul 2025 18:33:27 -0400 Subject: [PATCH 65/70] fix tests --- .../config_presets/_views/_config_presets_table.py | 12 ++++++++---- tests/test_config_groups_widgets.py | 5 ++--- 2 files changed, 10 insertions(+), 7 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 68ae8b1de..e9604a555 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from contextlib import suppress from typing import TYPE_CHECKING @@ -25,6 +26,8 @@ else: from qtpy.QtGui import QAction +NOT_TESTING = "PYTEST_VERSION" not in os.environ + class ConfigPresetsTableView(QTableView): """Plain QTableView for displaying configuration presets. @@ -197,10 +200,8 @@ 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, ask_confirmation=True) - # TODO: handle transposed case + source_idx = self._get_selected_preset_index() + self.view.sourceModel().remove(source_idx, ask_confirmation=NOT_TESTING) def _on_duplicate_action(self) -> None: if not self.view.isTransposed(): @@ -210,6 +211,9 @@ def _on_duplicate_action(self) -> None: 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() diff --git a/tests/test_config_groups_widgets.py b/tests/test_config_groups_widgets.py index 2d717edb3..3bbaae003 100644 --- a/tests/test_config_groups_widgets.py +++ b/tests/test_config_groups_widgets.py @@ -3,11 +3,10 @@ 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 QConfigGroupsModel +from pymmcore_widgets._models import ConfigGroup, QConfigGroupsModel from pymmcore_widgets.config_presets import ConfigPresetsTable 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 a4bb373768f242af6f45ae68bff3720e298d28d9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 6 Jul 2025 22:27:05 -0400 Subject: [PATCH 66/70] undo redo 1 --- .../_models/_q_config_model.py | 43 ++ .../_views/_config_groups_editor.py | 114 +++++- .../_views/_config_presets_table.py | 38 +- .../config_presets/_views/_undo_commands.py | 387 ++++++++++++++++++ test_undo_redo.py | 65 +++ 5 files changed, 638 insertions(+), 9 deletions(-) create mode 100644 src/pymmcore_widgets/config_presets/_views/_undo_commands.py create mode 100644 test_undo_redo.py diff --git a/src/pymmcore_widgets/_models/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py index 73b9a9fc4..aeb196b47 100644 --- a/src/pymmcore_widgets/_models/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -12,6 +12,13 @@ from pymmcore_widgets._icons import StandardIcon +# Create appropriate undo command based on what's being edited +from pymmcore_widgets.config_presets._views._undo_commands import ( + ChangePropertyValueCommand, + RenameGroupCommand, + RenamePresetCommand, +) + from ._base_tree_model import _BaseTreeModel, _Node from ._core_functions import get_config_groups from ._py_config_model import ConfigGroup, ConfigPreset, DevicePropertySetting @@ -42,9 +49,14 @@ def create_from_core(cls, core: CMMCorePlus) -> Self: def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: super().__init__() + self._undo_stack = None # Will be set by ConfigGroupsEditor if groups: self.set_groups(groups) + def setUndoStack(self, undo_stack) -> None: + """Set the undo stack for this model to enable undo/redo operations.""" + self._undo_stack = undo_stack + # ------------------------------------------------------------------ # Required Qt model overrides # ------------------------------------------------------------------ @@ -115,6 +127,37 @@ def setData( value: Any, role: int = Qt.ItemDataRole.EditRole, ) -> bool: + """Set data for the given index, creating undo commands if available.""" + if self._undo_stack is not None: + node = self._node_from_index(index) + if node is self._root or role != Qt.ItemDataRole.EditRole: + return False + + # Editing a property value + if node.is_setting: + command = ChangePropertyValueCommand(self, index, value) + self._undo_stack.push(command) + return True + # Editing a group or preset name + elif node.is_group: + command = RenameGroupCommand(self, index, str(value)) + self._undo_stack.push(command) + return True + elif node.is_preset: + command = RenamePresetCommand(self, index, str(value)) + self._undo_stack.push(command) + return True + + # Fall back to raw implementation if no undo stack + return self._raw_setData(index, value, role) + + def _raw_setData( + self, + index: QModelIndex, + value: Any, + role: int = Qt.ItemDataRole.EditRole, + ) -> bool: + """Perform the actual data change without creating undo commands.""" node = self._node_from_index(index) if node is self._root or role != Qt.ItemDataRole.EditRole: return False # pragma: no cover diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index cda447037..469bccef8 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, cast from qtpy.QtCore import QModelIndex, QSignalBlocker, QSize, Qt, Signal +from qtpy.QtGui import QUndoStack from qtpy.QtWidgets import ( QGroupBox, QSizePolicy, @@ -29,6 +30,16 @@ from ._config_presets_table import ConfigPresetsTable from ._device_property_selector import DevicePropertySelector from ._group_preset_selector import GroupPresetSelector +from ._undo_commands import ( + AddGroupCommand, + AddPresetCommand, + DuplicateGroupCommand, + DuplicatePresetCommand, + RemoveGroupCommand, + RemovePresetCommand, + SetChannelGroupCommand, + UpdatePresetPropertiesCommand, +) if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Sequence @@ -106,6 +117,10 @@ def __init__(self, parent: QWidget | None = None) -> None: self.setStyleSheet("QToolBar { border: none; };") self._model = QConfigGroupsModel() + self._undo_stack = QUndoStack(self) + + # Set up undo stack integration + self._model.setUndoStack(self._undo_stack) # widgets ------------------------------------------------------------- @@ -148,6 +163,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._prop_selector.checkedPropertiesChanged.connect( self._on_prop_selection_changed ) + self._model.dataChanged.connect(self._on_model_data_changed) # self._group_preset_stack.presetSelectionChanged.connect(self._on_preset_sel) # self._model.dataChanged.connect(self._on_model_data_changed) # self._props.valueChanged.connect(self._on_prop_table_changed) @@ -196,7 +212,8 @@ def _on_prop_selection_changed(self, props: Sequence[tuple[str, str]]) -> None: """ idx = self._group_preset_sel.currentPreset() if idx.isValid(): - self._model.update_preset_properties(idx, props) + command = UpdatePresetPropertiesCommand(self._model, idx, props) + self._undo_stack.push(command) def setCurrentGroup(self, group: str) -> None: """Set the currently selected group in the editor.""" @@ -226,11 +243,76 @@ def data(self) -> Sequence[ConfigGroup]: """Return the current configuration data as a list of ConfigGroup.""" return self._model.get_groups() + def undoStack(self) -> QUndoStack: + """Return the undo stack for this editor.""" + return self._undo_stack + def _add_preset_to_current_group(self) -> None: """Add a new preset to the currently selected group.""" current_group = self._group_preset_sel.currentGroup() if current_group.isValid(): - self._model.add_preset(current_group) + command = AddPresetCommand(self._model, current_group) + self._undo_stack.push(command) + + def _add_group(self) -> None: + """Add a new group.""" + command = AddGroupCommand(self._model) + self._undo_stack.push(command) + + def _remove_selected(self) -> None: + """Remove the currently selected group or preset.""" + idx = self._group_preset_sel._selected_index() + if idx.isValid(): + # Show confirmation dialog + from qtpy.QtWidgets import QMessageBox + + 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( + self, + "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 + + # Determine if it's a group or preset and create appropriate command + node = idx.internalPointer() + if hasattr(node, "is_group") and node.is_group: + command = RemoveGroupCommand(self._model, idx) + elif hasattr(node, "is_preset") and node.is_preset: + command = RemovePresetCommand(self._model, idx) + else: + return # Unknown item type + + self._undo_stack.push(command) + + def _duplicate_selected(self) -> None: + """Duplicate the currently selected group or preset.""" + if self._group_preset_sel.group_list.hasFocus(): + idx = self._group_preset_sel.group_list.currentIndex() + if idx.isValid(): + command = DuplicateGroupCommand(self._model, idx) + self._undo_stack.push(command) + elif self._group_preset_sel.preset_list.hasFocus(): + idx = self._group_preset_sel.preset_list.currentIndex() + if idx.isValid(): + command = DuplicatePresetCommand(self._model, idx) + self._undo_stack.push(command) + + def _on_model_data_changed( + self, + topLeft: QModelIndex, + bottomRight: QModelIndex, + roles: list[int] | None = None, + ) -> None: + """Handle model data changes to potentially create undo commands.""" + # For now, we'll just emit the configChanged signal + # In the future, we might want to create undo commands for direct model edits + self.configChanged.emit() # ------------------------------------------------------------------ # Layout management @@ -443,7 +525,7 @@ def __init__(self, parent: ConfigGroupsEditor) -> None: self.addAction( StandardIcon.FOLDER_ADD.icon(), "Add Group", - parent._model.add_group, + parent._add_group, ) self.addAction( StandardIcon.DOCUMENT_ADD.icon(), @@ -453,14 +535,29 @@ def __init__(self, parent: ConfigGroupsEditor) -> None: self.addAction( StandardIcon.DELETE.icon(), "Remove", - parent._group_preset_sel.removeSelected, + parent._remove_selected, ) self.addAction( StandardIcon.COPY.icon(), "Duplicate", - parent._group_preset_sel.duplicateSelected, + parent._duplicate_selected, ) self.addSeparator() + + # Undo/Redo actions + self.undo_action = parent._undo_stack.createUndoAction(self, "Undo") + if self.undo_action: + self.undo_action.setIcon(QIconifyIcon("fluent:arrow-undo-24-regular")) + self.undo_action.setShortcut("Ctrl+Z") + self.addAction(self.undo_action) + + self.redo_action = parent._undo_stack.createRedoAction(self, "Redo") + if self.redo_action: + self.redo_action.setIcon(QIconifyIcon("fluent:arrow-redo-24-regular")) + self.redo_action.setShortcut("Ctrl+Y") + self.addAction(self.redo_action) + + self.addSeparator() self.set_channel_action = cast( "QAction", self.addAction( @@ -471,8 +568,11 @@ def __init__(self, parent: ConfigGroupsEditor) -> None: @self.set_channel_action.triggered.connect # type: ignore[misc] def _on_set_channel_group() -> None: - parent._group_preset_sel.setCurrentGroupAsChannelGroup() - self.set_channel_action.setEnabled(False) + current_group = parent._group_preset_sel.currentGroup() + if current_group.isValid(): + command = SetChannelGroupCommand(parent._model, current_group) + parent._undo_stack.push(command) + self.set_channel_action.setEnabled(False) spacer = QWidget(self) spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) 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 e9604a555..c9aee70ac 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py @@ -201,12 +201,46 @@ def setGroup(self, group_name_or_index: str | QModelIndex) -> None: def _on_remove_action(self) -> None: source_idx = self._get_selected_preset_index() - self.view.sourceModel().remove(source_idx, ask_confirmation=NOT_TESTING) + if not source_idx.isValid(): + return + + source_model = self.view.sourceModel() + # Check if the model has an undo stack, if so use undo commands + if ( + hasattr(source_model, "_undo_stack") + and source_model._undo_stack is not None + ): + from pymmcore_widgets.config_presets._views._undo_commands import ( + RemovePresetCommand, + ) + + command = RemovePresetCommand(source_model, source_idx) + source_model._undo_stack.push(command) + else: + # Fall back to direct model operation + 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() + # Check if the model has an undo stack, if so use undo commands + if ( + hasattr(source_model, "_undo_stack") + and source_model._undo_stack is not None + ): + from pymmcore_widgets.config_presets._views._undo_commands import ( + DuplicatePresetCommand, + ) + + command = DuplicatePresetCommand(source_model, source_idx) + source_model._undo_stack.push(command) + else: + # Fall back to direct model operation + source_model.duplicate_preset(source_idx) # TODO: handle transposed case def _get_selected_preset_index(self) -> QModelIndex: diff --git a/src/pymmcore_widgets/config_presets/_views/_undo_commands.py b/src/pymmcore_widgets/config_presets/_views/_undo_commands.py new file mode 100644 index 000000000..8d338dec9 --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_views/_undo_commands.py @@ -0,0 +1,387 @@ +"""Undo/Redo command classes for ConfigGroupsEditor operations.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING, Any + +from qtpy.QtCore import QModelIndex + +if TYPE_CHECKING: + from collections.abc import Sequence + + from PyQt6.QtGui import QUndoCommand + + from pymmcore_widgets._models import QConfigGroupsModel + from pymmcore_widgets._models._py_config_model import ( + ConfigGroup, + ConfigPreset, + DevicePropertySetting, + ) +else: + from qtpy.QtGui import QUndoCommand + + +class AddGroupCommand(QUndoCommand): + """Command for adding a new config group.""" + + def __init__( + self, + model: QConfigGroupsModel, + name: str = "New Group", + parent: QUndoCommand | None = None, + ) -> None: + super().__init__(f"Add Group '{name}'", parent) + self._model = model + self._name = name + self._group_index: QModelIndex | None = None + + def redo(self) -> None: + """Execute the add group operation.""" + self._group_index = self._model.add_group(self._name) + + def undo(self) -> None: + """Undo the add group operation.""" + if self._group_index and self._group_index.isValid(): + self._model.removeRows(self._group_index.row(), 1, QModelIndex()) + + +class RemoveGroupCommand(QUndoCommand): + """Command for removing a config group.""" + + def __init__( + self, + model: QConfigGroupsModel, + group_index: QModelIndex, + parent: QUndoCommand | None = None, + ) -> None: + group_name = group_index.data() or "Group" + super().__init__(f"Remove Group '{group_name}'", parent) + self._model = model + self._group_index = group_index + self._row = group_index.row() + self._group_data: ConfigGroup | None = None + + def redo(self) -> None: + """Execute the remove group operation.""" + # Store the group data before removing + if self._group_index.isValid(): + self._group_data = deepcopy(self._group_index.data(0x0100)) # UserRole + self._model.removeRows(self._row, 1, QModelIndex()) + + def undo(self) -> None: + """Undo the remove group operation.""" + if self._group_data: + self._model.insertRows( + self._row, 1, QModelIndex(), _payloads=[self._group_data] + ) + + +class DuplicateGroupCommand(QUndoCommand): + """Command for duplicating a config group.""" + + def __init__( + self, + model: QConfigGroupsModel, + group_index: QModelIndex, + new_name: str | None = None, + parent: QUndoCommand | None = None, + ) -> None: + group_name = group_index.data() or "Group" + super().__init__(f"Duplicate Group '{group_name}'", parent) + self._model = model + self._group_index = group_index + self._new_name = new_name + self._new_group_index: QModelIndex | None = None + + def redo(self) -> None: + """Execute the duplicate group operation.""" + self._new_group_index = self._model.duplicate_group( + self._group_index, self._new_name + ) + + def undo(self) -> None: + """Undo the duplicate group operation.""" + if self._new_group_index and self._new_group_index.isValid(): + self._model.removeRows(self._new_group_index.row(), 1, QModelIndex()) + + +class RenameGroupCommand(QUndoCommand): + """Command for renaming a config group.""" + + def __init__( + self, + model: QConfigGroupsModel, + group_index: QModelIndex, + new_name: str, + parent: QUndoCommand | None = None, + ) -> None: + old_name = group_index.data() or "Group" + super().__init__(f"Rename Group '{old_name}' to '{new_name}'", parent) + self._model = model + self._group_index = group_index + self._old_name = old_name + self._new_name = new_name + + def redo(self) -> None: + """Execute the rename group operation.""" + self._model._raw_setData(self._group_index, self._new_name) + + def undo(self) -> None: + """Undo the rename group operation.""" + self._model._raw_setData(self._group_index, self._old_name) + + +class AddPresetCommand(QUndoCommand): + """Command for adding a new preset to a group.""" + + def __init__( + self, + model: QConfigGroupsModel, + group_index: QModelIndex, + name: str = "New Preset", + parent: QUndoCommand | None = None, + ) -> None: + group_name = group_index.data() or "Group" + super().__init__(f"Add Preset '{name}' to '{group_name}'", parent) + self._model = model + self._group_index = group_index + self._name = name + self._preset_index: QModelIndex | None = None + + def redo(self) -> None: + """Execute the add preset operation.""" + self._preset_index = self._model.add_preset(self._group_index, self._name) + + def undo(self) -> None: + """Undo the add preset operation.""" + if self._preset_index and self._preset_index.isValid(): + self._model.removeRows(self._preset_index.row(), 1, self._group_index) + + +class RemovePresetCommand(QUndoCommand): + """Command for removing a preset from a group.""" + + def __init__( + self, + model: QConfigGroupsModel, + preset_index: QModelIndex, + parent: QUndoCommand | None = None, + ) -> None: + preset_name = preset_index.data() or "Preset" + group_name = preset_index.parent().data() or "Group" + super().__init__(f"Remove Preset '{preset_name}' from '{group_name}'", parent) + self._model = model + self._preset_index = preset_index + self._group_index = preset_index.parent() + self._row = preset_index.row() + self._preset_data: ConfigPreset | None = None + + def redo(self) -> None: + """Execute the remove preset operation.""" + # Store the preset data before removing + if self._preset_index.isValid(): + self._preset_data = deepcopy(self._preset_index.data(0x0100)) # UserRole + self._model.removeRows(self._row, 1, self._group_index) + + def undo(self) -> None: + """Undo the remove preset operation.""" + if self._preset_data: + self._model.insertRows( + self._row, 1, self._group_index, _payloads=[self._preset_data] + ) + + +class DuplicatePresetCommand(QUndoCommand): + """Command for duplicating a preset.""" + + def __init__( + self, + model: QConfigGroupsModel, + preset_index: QModelIndex, + new_name: str | None = None, + parent: QUndoCommand | None = None, + ) -> None: + preset_name = preset_index.data() or "Preset" + super().__init__(f"Duplicate Preset '{preset_name}'", parent) + self._model = model + self._preset_index = preset_index + self._new_name = new_name + self._new_preset_index: QModelIndex | None = None + + def redo(self) -> None: + """Execute the duplicate preset operation.""" + self._new_preset_index = self._model.duplicate_preset( + self._preset_index, self._new_name + ) + + def undo(self) -> None: + """Undo the duplicate preset operation.""" + if self._new_preset_index and self._new_preset_index.isValid(): + group_index = self._new_preset_index.parent() + self._model.removeRows(self._new_preset_index.row(), 1, group_index) + + +class RenamePresetCommand(QUndoCommand): + """Command for renaming a preset.""" + + def __init__( + self, + model: QConfigGroupsModel, + preset_index: QModelIndex, + new_name: str, + parent: QUndoCommand | None = None, + ) -> None: + old_name = preset_index.data() or "Preset" + super().__init__(f"Rename Preset '{old_name}' to '{new_name}'", parent) + self._model = model + self._preset_index = preset_index + self._old_name = old_name + self._new_name = new_name + + def redo(self) -> None: + """Execute the rename preset operation.""" + self._model._raw_setData(self._preset_index, self._new_name) + + def undo(self) -> None: + """Undo the rename preset operation.""" + self._model._raw_setData(self._preset_index, self._old_name) + + +class UpdatePresetPropertiesCommand(QUndoCommand): + """Command for updating the properties in a preset.""" + + def __init__( + self, + model: QConfigGroupsModel, + preset_index: QModelIndex, + new_properties: Sequence[tuple[str, str]], + parent: QUndoCommand | None = None, + ) -> None: + preset_name = preset_index.data() or "Preset" + super().__init__(f"Update Properties in '{preset_name}'", parent) + self._model = model + self._preset_index = preset_index + self._new_properties = list(new_properties) + self._old_settings: list[DevicePropertySetting] | None = None + + def redo(self) -> None: + """Execute the update preset properties operation.""" + # Store the old settings before updating + if self._preset_index.isValid(): + preset_data = self._preset_index.data(0x0100) # UserRole + if preset_data: + self._old_settings = deepcopy(preset_data.settings) + + self._model.update_preset_properties(self._preset_index, self._new_properties) + + def undo(self) -> None: + """Undo the update preset properties operation.""" + if self._old_settings is not None: + self._model.update_preset_settings(self._preset_index, self._old_settings) + + +class UpdatePresetSettingsCommand(QUndoCommand): + """Command for updating all settings in a preset.""" + + def __init__( + self, + model: QConfigGroupsModel, + preset_index: QModelIndex, + new_settings: list[DevicePropertySetting], + parent: QUndoCommand | None = None, + ) -> None: + preset_name = preset_index.data() or "Preset" + super().__init__(f"Update Settings in '{preset_name}'", parent) + self._model = model + self._preset_index = preset_index + self._new_settings = deepcopy(new_settings) + self._old_settings: list[DevicePropertySetting] | None = None + + def redo(self) -> None: + """Execute the update preset settings operation.""" + # Store the old settings before updating + if self._preset_index.isValid(): + preset_data = self._preset_index.data(0x0100) # UserRole + if preset_data: + self._old_settings = deepcopy(preset_data.settings) + + self._model.update_preset_settings(self._preset_index, self._new_settings) + + def undo(self) -> None: + """Undo the update preset settings operation.""" + if self._old_settings is not None: + self._model.update_preset_settings(self._preset_index, self._old_settings) + + +class ChangePropertyValueCommand(QUndoCommand): + """Command for changing a single property value in a preset.""" + + def __init__( + self, + model: QConfigGroupsModel, + property_index: QModelIndex, + new_value: Any, + parent: QUndoCommand | None = None, + ) -> None: + preset_index = property_index.parent() + preset_name = preset_index.data() or "Preset" + super().__init__(f"Change Property Value in '{preset_name}'", parent) + self._model = model + self._property_index = property_index + self._new_value = new_value + self._old_value: Any = None + + def redo(self) -> None: + """Execute the change property value operation.""" + # Store the old value before changing + if self._property_index.isValid(): + self._old_value = self._property_index.data() + + self._model._raw_setData(self._property_index, self._new_value) + + def undo(self) -> None: + """Undo the change property value operation.""" + if self._old_value is not None: + self._model._raw_setData(self._property_index, self._old_value) + + +class SetChannelGroupCommand(QUndoCommand): + """Command for setting/unsetting a group as the channel group.""" + + def __init__( + self, + model: QConfigGroupsModel, + group_index: QModelIndex | None, + parent: QUndoCommand | None = None, + ) -> None: + if group_index: + group_name = group_index.data() or "Group" + super().__init__(f"Set '{group_name}' as Channel Group", parent) + else: + super().__init__("Unset Channel Group", parent) + + self._model = model + self._new_group_index = group_index + self._old_channel_group_index: QModelIndex | None = None + + def redo(self) -> None: + """Execute the set channel group operation.""" + # Find and store the current channel group + self._old_channel_group_index = None + for i in range(self._model.rowCount()): + idx = self._model.index(i, 0) + group_data = idx.data(0x0100) # UserRole + if ( + group_data + and hasattr(group_data, "is_channel_group") + and group_data.is_channel_group + ): + self._old_channel_group_index = idx + break + + self._model.set_channel_group(self._new_group_index) + + def undo(self) -> None: + """Undo the set channel group operation.""" + self._model.set_channel_group(self._old_channel_group_index) diff --git a/test_undo_redo.py b/test_undo_redo.py new file mode 100644 index 000000000..7553c30e4 --- /dev/null +++ b/test_undo_redo.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Simple test script to verify undo/redo functionality in ConfigGroupsEditor.""" + +import sys + +from qtpy.QtWidgets import QApplication + +from pymmcore_widgets._models._py_config_model import ( + ConfigGroup, + ConfigPreset, + DevicePropertySetting, +) +from pymmcore_widgets.config_presets import ConfigGroupsEditor + + +def test_undo_redo(): + """Test basic undo/redo functionality.""" + QApplication.instance() or QApplication(sys.argv) + + # Create an editor + editor = ConfigGroupsEditor() + + # Create some test data + group1 = ConfigGroup( + name="Test Group", + presets=[ + ConfigPreset( + name="Preset1", + settings=[DevicePropertySetting("Camera", "Exposure", "100")], + ) + ], + ) + + editor.setData([group1]) + + # Test undo stack is available + undo_stack = editor.undoStack() + assert undo_stack is not None + + # Test adding a group (should be undoable) + initial_count = len(editor.data()) + editor._add_group() + + # Should have one more group + assert len(editor.data()) == initial_count + 1 + + # Should be able to undo + assert undo_stack.canUndo() + undo_stack.undo() + + # Should be back to original count + assert len(editor.data()) == initial_count + + # Should be able to redo + assert undo_stack.canRedo() + undo_stack.redo() + + # Should have the group again + assert len(editor.data()) == initial_count + 1 + + print("✓ Basic undo/redo functionality works!") + + +if __name__ == "__main__": + test_undo_redo() From 087e0c0d42c20e15f75d1f88c2f77fb4123c3701 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 6 Jul 2025 23:30:59 -0400 Subject: [PATCH 67/70] undo redo 2 --- .../_models/_q_config_model.py | 44 +------ .../_views/_config_groups_editor.py | 20 ++-- .../_views/_config_groups_tree.py | 20 ++++ .../_views/_config_presets_table.py | 44 +++---- .../_views/_group_preset_selector.py | 83 ++++++++++++-- .../config_presets/_views/_undo_commands.py | 65 +++++++---- .../config_presets/_views/_undo_delegates.py | 108 ++++++++++++++++++ test_undo_redo.py | 65 ----------- 8 files changed, 284 insertions(+), 165 deletions(-) create mode 100644 src/pymmcore_widgets/config_presets/_views/_undo_delegates.py delete mode 100644 test_undo_redo.py diff --git a/src/pymmcore_widgets/_models/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py index aeb196b47..924edbfc6 100644 --- a/src/pymmcore_widgets/_models/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -12,13 +12,6 @@ from pymmcore_widgets._icons import StandardIcon -# Create appropriate undo command based on what's being edited -from pymmcore_widgets.config_presets._views._undo_commands import ( - ChangePropertyValueCommand, - RenameGroupCommand, - RenamePresetCommand, -) - from ._base_tree_model import _BaseTreeModel, _Node from ._core_functions import get_config_groups from ._py_config_model import ConfigGroup, ConfigPreset, DevicePropertySetting @@ -49,14 +42,9 @@ def create_from_core(cls, core: CMMCorePlus) -> Self: def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: super().__init__() - self._undo_stack = None # Will be set by ConfigGroupsEditor if groups: self.set_groups(groups) - def setUndoStack(self, undo_stack) -> None: - """Set the undo stack for this model to enable undo/redo operations.""" - self._undo_stack = undo_stack - # ------------------------------------------------------------------ # Required Qt model overrides # ------------------------------------------------------------------ @@ -127,37 +115,7 @@ def setData( value: Any, role: int = Qt.ItemDataRole.EditRole, ) -> bool: - """Set data for the given index, creating undo commands if available.""" - if self._undo_stack is not None: - node = self._node_from_index(index) - if node is self._root or role != Qt.ItemDataRole.EditRole: - return False - - # Editing a property value - if node.is_setting: - command = ChangePropertyValueCommand(self, index, value) - self._undo_stack.push(command) - return True - # Editing a group or preset name - elif node.is_group: - command = RenameGroupCommand(self, index, str(value)) - self._undo_stack.push(command) - return True - elif node.is_preset: - command = RenamePresetCommand(self, index, str(value)) - self._undo_stack.push(command) - return True - - # Fall back to raw implementation if no undo stack - return self._raw_setData(index, value, role) - - def _raw_setData( - self, - index: QModelIndex, - value: Any, - role: int = Qt.ItemDataRole.EditRole, - ) -> bool: - """Perform the actual data change without creating undo commands.""" + """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 diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index 469bccef8..bfae6a003 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -119,9 +119,6 @@ def __init__(self, parent: QWidget | None = None) -> None: self._model = QConfigGroupsModel() self._undo_stack = QUndoStack(self) - # Set up undo stack integration - self._model.setUndoStack(self._undo_stack) - # widgets ------------------------------------------------------------- # The GroupPresetSelector can switch between 2-list and tree views: @@ -145,6 +142,11 @@ def __init__(self, parent: QWidget | None = None) -> None: self._preset_table.setModel(self._model) self._preset_table.setGroup("Channel") + # Set up undo/redo integration + self._group_preset_sel.setUndoStack(self._undo_stack) + self._preset_table.setUndoStack(self._undo_stack) + self._preset_table.view.setUndoStack(self._undo_stack) + # define this after the other widgets so that it can connect to their slots self._tb = _ConfigEditorToolbar(self) @@ -292,14 +294,14 @@ def _remove_selected(self) -> None: def _duplicate_selected(self) -> None: """Duplicate the currently selected group or preset.""" - if self._group_preset_sel.group_list.hasFocus(): - idx = self._group_preset_sel.group_list.currentIndex() - if idx.isValid(): + idx = self._group_preset_sel._selected_index() + if idx.isValid(): + # Determine if it's a group or preset and create appropriate command + node = idx.internalPointer() + if hasattr(node, "is_group") and node.is_group: command = DuplicateGroupCommand(self._model, idx) self._undo_stack.push(command) - elif self._group_preset_sel.preset_list.hasFocus(): - idx = self._group_preset_sel.preset_list.currentIndex() - if idx.isValid(): + elif hasattr(node, "is_preset") and node.is_preset: command = DuplicatePresetCommand(self._model, idx) self._undo_stack.push(command) 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 fe1bb09ee..35fb4c109 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py @@ -8,10 +8,15 @@ from pymmcore_widgets.config_presets._views._property_setting_delegate import ( PropertySettingDelegate, ) +from pymmcore_widgets.config_presets._views._undo_delegates import ( + GroupPresetRenameDelegate, + PropertyValueDelegate, +) if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus from qtpy.QtCore import QAbstractItemModel + from qtpy.QtGui import QUndoStack class ConfigGroupsTree(QTreeView): @@ -29,8 +34,23 @@ def create_from_core( def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) + self._undo_stack: QUndoStack | None = None + # Set initial delegates (will be replaced if undo stack is set) self.setItemDelegateForColumn(2, PropertySettingDelegate(self)) + def setUndoStack(self, undo_stack: QUndoStack) -> None: + """Set the undo stack and configure undo-aware delegates.""" + self._undo_stack = undo_stack + + if undo_stack is not None: + # Set up undo-aware delegates for different columns + # Column 0 (names): for renaming groups/presets + self.setItemDelegateForColumn( + 0, GroupPresetRenameDelegate(undo_stack, self) + ) + # Column 2 (values): for property value changes + self.setItemDelegateForColumn(2, PropertyValueDelegate(undo_stack, self)) + def setModel(self, model: QAbstractItemModel | None) -> None: """Set the model for the tree view.""" super().setModel(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 c9aee70ac..043774bb0 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_presets_table.py @@ -18,10 +18,13 @@ from pymmcore_widgets._models import ConfigGroupPivotModel, QConfigGroupsModel from ._property_setting_delegate import PropertySettingDelegate +from ._undo_commands import DuplicatePresetCommand, RemovePresetCommand +from ._undo_delegates import PropertyValueDelegate if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus from PyQt6.QtGui import QAction + from qtpy.QtGui import QUndoStack else: from qtpy.QtGui import QAction @@ -44,6 +47,15 @@ def __init__(self, parent: QWidget | None = None) -> None: self.setItemDelegate(PropertySettingDelegate(self)) self._transpose_proxy: QTransposeProxyModel | None = None self._pivot_model: ConfigGroupPivotModel | None = None + self._undo_stack: QUndoStack | None = None + + def setUndoStack(self, undo_stack: QUndoStack) -> None: + """Set the undo stack and configure undo-aware delegates.""" + self._undo_stack = undo_stack + + if undo_stack is not None: + # Replace the delegate with an undo-aware one + self.setItemDelegate(PropertyValueDelegate(undo_stack, self)) def setModel(self, model: QAbstractItemModel | None) -> None: """Set the model for the table view.""" @@ -162,6 +174,7 @@ def create_from_core( def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.view = ConfigPresetsTableView(self) + self._undo_stack: QUndoStack | None = None self._toolbar = tb = QToolBar(self) tb.setIconSize(QSize(16, 16)) @@ -199,23 +212,21 @@ def setGroup(self, group_name_or_index: str | QModelIndex) -> None: """Set the group to be displayed.""" self.view.setGroup(group_name_or_index) + def setUndoStack(self, undo_stack: QUndoStack) -> None: + """Set the undo stack for remove/duplicate operations.""" + self._undo_stack = undo_stack + self.view.setUndoStack(undo_stack) + def _on_remove_action(self) -> None: source_idx = self._get_selected_preset_index() if not source_idx.isValid(): return source_model = self.view.sourceModel() - # Check if the model has an undo stack, if so use undo commands - if ( - hasattr(source_model, "_undo_stack") - and source_model._undo_stack is not None - ): - from pymmcore_widgets.config_presets._views._undo_commands import ( - RemovePresetCommand, - ) - + # Use undo stack if available, otherwise fall back to direct operation + if self._undo_stack is not None: command = RemovePresetCommand(source_model, source_idx) - source_model._undo_stack.push(command) + self._undo_stack.push(command) else: # Fall back to direct model operation source_model.remove(source_idx, ask_confirmation=NOT_TESTING) @@ -227,17 +238,10 @@ def _on_duplicate_action(self) -> None: return source_model = self.view.sourceModel() - # Check if the model has an undo stack, if so use undo commands - if ( - hasattr(source_model, "_undo_stack") - and source_model._undo_stack is not None - ): - from pymmcore_widgets.config_presets._views._undo_commands import ( - DuplicatePresetCommand, - ) - + # Use undo stack if available, otherwise fall back to direct operation + if self._undo_stack is not None: command = DuplicatePresetCommand(source_model, source_idx) - source_model._undo_stack.push(command) + self._undo_stack.push(command) else: # Fall back to direct model operation source_model.duplicate_preset(source_idx) diff --git a/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py b/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py index 4a0fb2c37..cc97984b5 100644 --- a/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py +++ b/src/pymmcore_widgets/config_presets/_views/_group_preset_selector.py @@ -8,9 +8,11 @@ from pymmcore_widgets._models import QConfigGroupsModel from ._config_groups_tree import ConfigGroupsTree +from ._undo_delegates import GroupPresetRenameDelegate if TYPE_CHECKING: from qtpy.QtCore import QAbstractItemModel + from qtpy.QtGui import QUndoStack class GroupPresetSelector(QStackedWidget): @@ -28,6 +30,7 @@ def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._model: QConfigGroupsModel | None = None + self._undo_stack: QUndoStack | None = None # STACK_0 : List views for groups and presets ---------------------- @@ -57,6 +60,21 @@ def __init__(self, parent: QWidget | None = None) -> None: self.addWidget(lists_splitter) # index 0 self.addWidget(self.config_groups_tree) # index 1 + def setUndoStack(self, undo_stack: QUndoStack) -> None: + """Set the undo stack and configure delegates for undo/redo.""" + self._undo_stack = undo_stack + + # Set up undo-aware delegates for the list views + # The list views handle group/preset renaming + if undo_stack is not None: + rename_delegate = GroupPresetRenameDelegate(undo_stack, self) + self.group_list.setItemDelegate(rename_delegate) + self.preset_list.setItemDelegate(rename_delegate) + + # The tree view already has PropertySettingDelegate set up, + # but we need to update it to use undo commands + self.config_groups_tree.setUndoStack(undo_stack) + def model(self) -> QConfigGroupsModel | None: """Return the currently attached model.""" return self._model @@ -147,26 +165,77 @@ def _on_tree_selection_changed( def _selected_index(self) -> QModelIndex: """Return the currently selected index from the group or preset list.""" + # First check focus-based selection if self.group_list.hasFocus(): return self.group_list.currentIndex() elif self.preset_list.hasFocus(): return self.preset_list.currentIndex() elif self.config_groups_tree.hasFocus(): return self.config_groups_tree.currentIndex() + + # If no view has focus, check for valid current selections + # This handles cases where focus has moved to toolbar buttons + if self.currentIndex() == 0: # Column view is active + # Check preset list first since presets are more commonly operated on + preset_idx = self.preset_list.currentIndex() + if preset_idx.isValid(): + return preset_idx + # Fall back to group list + group_idx = self.group_list.currentIndex() + if group_idx.isValid(): + return group_idx + else: # Tree view is active + tree_idx = self.config_groups_tree.currentIndex() + if tree_idx.isValid(): + return tree_idx + return QModelIndex() def removeSelected(self) -> None: if self._model: - self._model.remove( - self._selected_index(), ask_confirmation=True, parent=self - ) + selected_idx = self._selected_index() + if self._undo_stack is not None: + from pymmcore_widgets.config_presets._views._undo_commands import ( + RemoveGroupCommand, + RemovePresetCommand, + ) + + node = self._model._node_from_index(selected_idx) + if node.is_group: + command = RemoveGroupCommand(self._model, selected_idx) + self._undo_stack.push(command) + elif node.is_preset: + command = RemovePresetCommand(self._model, selected_idx) + self._undo_stack.push(command) + else: + # Fall back to direct model operation + self._model.remove(selected_idx, ask_confirmation=True, parent=self) def duplicateSelected(self) -> None: if self._model: - if self.group_list.hasFocus(): - self._model.duplicate_group(self.group_list.currentIndex()) - elif self.preset_list.hasFocus(): - self._model.duplicate_preset(self.preset_list.currentIndex()) + if self._undo_stack is not None: + from pymmcore_widgets.config_presets._views._undo_commands import ( + DuplicateGroupCommand, + DuplicatePresetCommand, + ) + + selected_idx = self._selected_index() + if not selected_idx.isValid(): + return + + node = self._model._node_from_index(selected_idx) + if node.is_group: + command = DuplicateGroupCommand(self._model, selected_idx) + self._undo_stack.push(command) + elif node.is_preset: + command = DuplicatePresetCommand(self._model, selected_idx) + self._undo_stack.push(command) + else: + # Fall back to direct model operations + if self.group_list.hasFocus(): + self._model.duplicate_group(self.group_list.currentIndex()) + elif self.preset_list.hasFocus(): + self._model.duplicate_preset(self.preset_list.currentIndex()) def showColumnView(self) -> None: """Switch to column view mode (groups and presets side by side).""" diff --git a/src/pymmcore_widgets/config_presets/_views/_undo_commands.py b/src/pymmcore_widgets/config_presets/_views/_undo_commands.py index 8d338dec9..4af68c232 100644 --- a/src/pymmcore_widgets/config_presets/_views/_undo_commands.py +++ b/src/pymmcore_widgets/config_presets/_views/_undo_commands.py @@ -12,11 +12,11 @@ from PyQt6.QtGui import QUndoCommand - from pymmcore_widgets._models import QConfigGroupsModel - from pymmcore_widgets._models._py_config_model import ( + from pymmcore_widgets._models import ( ConfigGroup, ConfigPreset, DevicePropertySetting, + QConfigGroupsModel, ) else: from qtpy.QtGui import QUndoCommand @@ -31,7 +31,8 @@ def __init__( name: str = "New Group", parent: QUndoCommand | None = None, ) -> None: - super().__init__(f"Add Group '{name}'", parent) + # the \n separates ActionText from text used in QUndoStackView + super().__init__(f"Add Group '{name}'\nAdd Group", parent) self._model = model self._name = name self._group_index: QModelIndex | None = None @@ -56,7 +57,8 @@ def __init__( parent: QUndoCommand | None = None, ) -> None: group_name = group_index.data() or "Group" - super().__init__(f"Remove Group '{group_name}'", parent) + # the \n separates ActionText from text used in QUndoStackView + super().__init__(f"Remove Group '{group_name}'\nRemove Group", parent) self._model = model self._group_index = group_index self._row = group_index.row() @@ -88,7 +90,8 @@ def __init__( parent: QUndoCommand | None = None, ) -> None: group_name = group_index.data() or "Group" - super().__init__(f"Duplicate Group '{group_name}'", parent) + # the \n separates ActionText from text used in QUndoStackView + super().__init__(f"Duplicate Group '{group_name}'\nDuplicate Group", parent) self._model = model self._group_index = group_index self._new_name = new_name @@ -117,7 +120,10 @@ def __init__( parent: QUndoCommand | None = None, ) -> None: old_name = group_index.data() or "Group" - super().__init__(f"Rename Group '{old_name}' to '{new_name}'", parent) + # the \n separates ActionText from text used in QUndoStackView + super().__init__( + f"Rename Group '{old_name}' to '{new_name}'\nRename Group", parent + ) self._model = model self._group_index = group_index self._old_name = old_name @@ -125,11 +131,11 @@ def __init__( def redo(self) -> None: """Execute the rename group operation.""" - self._model._raw_setData(self._group_index, self._new_name) + self._model.setData(self._group_index, self._new_name) def undo(self) -> None: """Undo the rename group operation.""" - self._model._raw_setData(self._group_index, self._old_name) + self._model.setData(self._group_index, self._old_name) class AddPresetCommand(QUndoCommand): @@ -143,7 +149,8 @@ def __init__( parent: QUndoCommand | None = None, ) -> None: group_name = group_index.data() or "Group" - super().__init__(f"Add Preset '{name}' to '{group_name}'", parent) + # the \n separates ActionText from text used in QUndoStackView + super().__init__(f"Add Preset '{name}' to '{group_name}'\nAdd Preset", parent) self._model = model self._group_index = group_index self._name = name @@ -170,7 +177,10 @@ def __init__( ) -> None: preset_name = preset_index.data() or "Preset" group_name = preset_index.parent().data() or "Group" - super().__init__(f"Remove Preset '{preset_name}' from '{group_name}'", parent) + # the \n separates ActionText from text used in QUndoStackView + super().__init__( + f"Remove Preset '{preset_name}' from '{group_name}'\nRemove Preset", parent + ) self._model = model self._preset_index = preset_index self._group_index = preset_index.parent() @@ -203,7 +213,8 @@ def __init__( parent: QUndoCommand | None = None, ) -> None: preset_name = preset_index.data() or "Preset" - super().__init__(f"Duplicate Preset '{preset_name}'", parent) + # the \n separates ActionText from text used in QUndoStackView + super().__init__(f"Duplicate Preset '{preset_name}'\nDuplicate Preset", parent) self._model = model self._preset_index = preset_index self._new_name = new_name @@ -233,7 +244,10 @@ def __init__( parent: QUndoCommand | None = None, ) -> None: old_name = preset_index.data() or "Preset" - super().__init__(f"Rename Preset '{old_name}' to '{new_name}'", parent) + # the \n separates ActionText from text used in QUndoStackView + super().__init__( + f"Rename Preset '{old_name}' to '{new_name}'\nRename Preset", parent + ) self._model = model self._preset_index = preset_index self._old_name = old_name @@ -241,11 +255,11 @@ def __init__( def redo(self) -> None: """Execute the rename preset operation.""" - self._model._raw_setData(self._preset_index, self._new_name) + self._model.setData(self._preset_index, self._new_name) def undo(self) -> None: """Undo the rename preset operation.""" - self._model._raw_setData(self._preset_index, self._old_name) + self._model.setData(self._preset_index, self._old_name) class UpdatePresetPropertiesCommand(QUndoCommand): @@ -259,7 +273,10 @@ def __init__( parent: QUndoCommand | None = None, ) -> None: preset_name = preset_index.data() or "Preset" - super().__init__(f"Update Properties in '{preset_name}'", parent) + # the \n separates ActionText from text used in QUndoStackView + super().__init__( + f"Update Properties in '{preset_name}'\nUpdate Properties", parent + ) self._model = model self._preset_index = preset_index self._new_properties = list(new_properties) @@ -292,7 +309,8 @@ def __init__( parent: QUndoCommand | None = None, ) -> None: preset_name = preset_index.data() or "Preset" - super().__init__(f"Update Settings in '{preset_name}'", parent) + # the \n separates ActionText from text used in QUndoStackView + super().__init__(f"Update Settings in '{preset_name}'\nUpdate Settings", parent) self._model = model self._preset_index = preset_index self._new_settings = deepcopy(new_settings) @@ -326,7 +344,10 @@ def __init__( ) -> None: preset_index = property_index.parent() preset_name = preset_index.data() or "Preset" - super().__init__(f"Change Property Value in '{preset_name}'", parent) + # the \n separates ActionText from text used in QUndoStackView + super().__init__( + f"Change Property Value in '{preset_name}'\nChange Property Value", parent + ) self._model = model self._property_index = property_index self._new_value = new_value @@ -338,12 +359,12 @@ def redo(self) -> None: if self._property_index.isValid(): self._old_value = self._property_index.data() - self._model._raw_setData(self._property_index, self._new_value) + self._model.setData(self._property_index, self._new_value) def undo(self) -> None: """Undo the change property value operation.""" if self._old_value is not None: - self._model._raw_setData(self._property_index, self._old_value) + self._model.setData(self._property_index, self._old_value) class SetChannelGroupCommand(QUndoCommand): @@ -357,9 +378,11 @@ def __init__( ) -> None: if group_index: group_name = group_index.data() or "Group" - super().__init__(f"Set '{group_name}' as Channel Group", parent) + super().__init__( + f"Set '{group_name}' as Channel Group\nChange Channel Group", parent + ) else: - super().__init__("Unset Channel Group", parent) + super().__init__("Unset Channel Group\nUnset Channel Group", parent) self._model = model self._new_group_index = group_index diff --git a/src/pymmcore_widgets/config_presets/_views/_undo_delegates.py b/src/pymmcore_widgets/config_presets/_views/_undo_delegates.py new file mode 100644 index 000000000..f31ce4354 --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_views/_undo_delegates.py @@ -0,0 +1,108 @@ +"""Custom delegates that handle undo/redo for config group editing.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import QModelIndex, QObject, Qt +from qtpy.QtWidgets import QStyledItemDelegate, QWidget + +from pymmcore_widgets._models import QConfigGroupsModel +from pymmcore_widgets.device_properties import PropertyWidget + +from ._property_setting_delegate import PropertySettingDelegate +from ._undo_commands import ( + ChangePropertyValueCommand, + RenameGroupCommand, + RenamePresetCommand, +) + +if TYPE_CHECKING: + from qtpy.QtCore import QAbstractItemModel + from qtpy.QtGui import QUndoStack + + +class GroupPresetRenameDelegate(QStyledItemDelegate): + """Delegate for renaming groups and presets in list views with undo support.""" + + def __init__(self, undo_stack: QUndoStack, parent: QObject | None = None) -> None: + super().__init__(parent) + self._undo_stack = undo_stack + + def setModelData( + self, + editor: QWidget | None, + model: QAbstractItemModel | None, + index: QModelIndex, + ) -> None: + """Override to create undo commands for group/preset renames.""" + if ( + not index.isValid() + or editor is None + or not isinstance(model, QConfigGroupsModel) + ): + return super().setModelData(editor, model, index) # type: ignore [no-any-return] + + # Get the new value from the editor + new_value = None + try: + if hasattr(editor, "text"): + new_value = editor.text() + elif hasattr(editor, "currentText"): + new_value = editor.currentText() + elif hasattr(editor, "value"): + new_value = editor.value() + except (AttributeError, TypeError): + pass + + if new_value is None: + return + + # Get the current value before changing it + old_value = index.data(Qt.ItemDataRole.EditRole) + if old_value == new_value: + return # No change + + # Create rename commands for groups/presets + node = model._node_from_index(index) + if node.is_group: + self._undo_stack.push(RenameGroupCommand(model, index, str(new_value))) + elif node.is_preset: + self._undo_stack.push(RenamePresetCommand(model, index, str(new_value))) + + +class PropertyValueDelegate(PropertySettingDelegate): + """Delegate that uses PropertyWidgets and handles undo/redo for property values.""" + + def __init__(self, undo_stack: QUndoStack, parent: QObject | None = None) -> None: + super().__init__(parent) + self._undo_stack = undo_stack + + def setModelData( + self, + editor: QWidget | None, + model: QAbstractItemModel | None, + index: QModelIndex, + ) -> None: + """Override to create undo commands for property value changes.""" + if ( + not index.isValid() + or editor is None + or not isinstance(model, QConfigGroupsModel) + or not isinstance(editor, PropertyWidget) + ): + # Fall back to parent implementation for non-property editors + return super().setModelData(editor, model, index) + + # Get the new value from the PropertyWidget + new_value = editor.value() + + # Get the current value before changing it + old_value = index.data(Qt.ItemDataRole.EditRole) + if old_value == new_value: + return # No change + + # Create and push the undo command for property value changes + node = model._node_from_index(index) + if node.is_setting: + self._undo_stack.push(ChangePropertyValueCommand(model, index, new_value)) diff --git a/test_undo_redo.py b/test_undo_redo.py deleted file mode 100644 index 7553c30e4..000000000 --- a/test_undo_redo.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -"""Simple test script to verify undo/redo functionality in ConfigGroupsEditor.""" - -import sys - -from qtpy.QtWidgets import QApplication - -from pymmcore_widgets._models._py_config_model import ( - ConfigGroup, - ConfigPreset, - DevicePropertySetting, -) -from pymmcore_widgets.config_presets import ConfigGroupsEditor - - -def test_undo_redo(): - """Test basic undo/redo functionality.""" - QApplication.instance() or QApplication(sys.argv) - - # Create an editor - editor = ConfigGroupsEditor() - - # Create some test data - group1 = ConfigGroup( - name="Test Group", - presets=[ - ConfigPreset( - name="Preset1", - settings=[DevicePropertySetting("Camera", "Exposure", "100")], - ) - ], - ) - - editor.setData([group1]) - - # Test undo stack is available - undo_stack = editor.undoStack() - assert undo_stack is not None - - # Test adding a group (should be undoable) - initial_count = len(editor.data()) - editor._add_group() - - # Should have one more group - assert len(editor.data()) == initial_count + 1 - - # Should be able to undo - assert undo_stack.canUndo() - undo_stack.undo() - - # Should be back to original count - assert len(editor.data()) == initial_count - - # Should be able to redo - assert undo_stack.canRedo() - undo_stack.redo() - - # Should have the group again - assert len(editor.data()) == initial_count + 1 - - print("✓ Basic undo/redo functionality works!") - - -if __name__ == "__main__": - test_undo_redo() From 2375230ff97b6324c2aa0969d7695f04a52b47a7 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 6 Jul 2025 23:49:39 -0400 Subject: [PATCH 68/70] undo redo 3 --- src/pymmcore_widgets/_icons.py | 2 ++ .../_models/_base_tree_model.py | 7 +++- .../_models/_q_config_model.py | 1 + .../_views/_config_groups_editor.py | 26 ++++++++++++--- .../config_presets/_views/_undo_commands.py | 32 +++++++------------ 5 files changed, 41 insertions(+), 27 deletions(-) diff --git a/src/pymmcore_widgets/_icons.py b/src/pymmcore_widgets/_icons.py index 43d37deed..7e4bc7d36 100644 --- a/src/pymmcore_widgets/_icons.py +++ b/src/pymmcore_widgets/_icons.py @@ -45,6 +45,8 @@ class StandardIcon(str, Enum): 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) diff --git a/src/pymmcore_widgets/_models/_base_tree_model.py b/src/pymmcore_widgets/_models/_base_tree_model.py index 4ae58b5c6..1905daa4c 100644 --- a/src/pymmcore_widgets/_models/_base_tree_model.py +++ b/src/pymmcore_widgets/_models/_base_tree_model.py @@ -77,7 +77,12 @@ def num_children(self) -> int: return len(self.children) def row_in_parent(self) -> int: - return -1 if self.parent is None else self.parent.children.index(self) + if self.parent is None: + return -1 + try: + return self.parent.children.index(self) + except ValueError: # pragma: no cover + return -1 # type helpers ----------------------------------------------------------- diff --git a/src/pymmcore_widgets/_models/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py index 924edbfc6..3a3643b48 100644 --- a/src/pymmcore_widgets/_models/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -233,6 +233,7 @@ def duplicate_group( raise ValueError("Reference index is not a ConfigGroup.") new_grp = deepcopy(grp) + new_grp.is_channel_group = False # this never gets duplicated new_grp.name = new_name or self._unique_child_name(self._root, new_grp.name) row = idx.row() + 1 if self.insertRows(row, 1, QModelIndex(), _payloads=[new_grp]): diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index bfae6a003..4e18a83a1 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, cast from qtpy.QtCore import QModelIndex, QSignalBlocker, QSize, Qt, Signal -from qtpy.QtGui import QUndoStack +from qtpy.QtGui import QKeySequence, QUndoStack from qtpy.QtWidgets import ( QGroupBox, QSizePolicy, @@ -173,6 +173,7 @@ def __init__(self, parent: QWidget | None = None) -> None: # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ + def _on_group_changed(self, current: QModelIndex, previous: QModelIndex) -> None: """Called when the group selection in the GroupPresetSelector changes.""" # Show this group in the preset table @@ -447,6 +448,20 @@ def _show_help(self) -> None: dialog.setWindowFlags(Qt.WindowType.Sheet | Qt.WindowType.WindowCloseButtonHint) dialog.exec() + def _show_undo_view(self) -> None: + """Show a dialog with the undo stack view.""" + from qtpy.QtWidgets import QUndoView + + # TODO + if self._undo_stack is not None: + dialog = QUndoView(self._undo_stack, self) + dialog.setCleanIcon(StandardIcon.UNDO.icon()) + dialog.setEmptyLabel("") + dialog.setWindowFlags( + Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint + ) + dialog.show() + # ------------------------------------------------------------------ # Property-table sync # ------------------------------------------------------------------ @@ -549,16 +564,17 @@ def __init__(self, parent: ConfigGroupsEditor) -> None: # Undo/Redo actions self.undo_action = parent._undo_stack.createUndoAction(self, "Undo") if self.undo_action: - self.undo_action.setIcon(QIconifyIcon("fluent:arrow-undo-24-regular")) - self.undo_action.setShortcut("Ctrl+Z") + self.undo_action.setIcon(StandardIcon.UNDO.icon()) + self.undo_action.setShortcut(QKeySequence.StandardKey.Undo) self.addAction(self.undo_action) self.redo_action = parent._undo_stack.createRedoAction(self, "Redo") if self.redo_action: - self.redo_action.setIcon(QIconifyIcon("fluent:arrow-redo-24-regular")) - self.redo_action.setShortcut("Ctrl+Y") + self.redo_action.setIcon(StandardIcon.REDO.icon()) + self.redo_action.setShortcut(QKeySequence.StandardKey.Redo) self.addAction(self.redo_action) + self.addAction("Show Undo/Redo History...", parent._show_undo_view) self.addSeparator() self.set_channel_action = cast( "QAction", diff --git a/src/pymmcore_widgets/config_presets/_views/_undo_commands.py b/src/pymmcore_widgets/config_presets/_views/_undo_commands.py index 4af68c232..d8f20f2b8 100644 --- a/src/pymmcore_widgets/config_presets/_views/_undo_commands.py +++ b/src/pymmcore_widgets/config_presets/_views/_undo_commands.py @@ -32,7 +32,7 @@ def __init__( parent: QUndoCommand | None = None, ) -> None: # the \n separates ActionText from text used in QUndoStackView - super().__init__(f"Add Group '{name}'\nAdd Group", parent) + super().__init__(f"Add Group '{name}'\n", parent) self._model = model self._name = name self._group_index: QModelIndex | None = None @@ -58,7 +58,7 @@ def __init__( ) -> None: group_name = group_index.data() or "Group" # the \n separates ActionText from text used in QUndoStackView - super().__init__(f"Remove Group '{group_name}'\nRemove Group", parent) + super().__init__(f"Remove Group '{group_name}'\n", parent) self._model = model self._group_index = group_index self._row = group_index.row() @@ -91,7 +91,7 @@ def __init__( ) -> None: group_name = group_index.data() or "Group" # the \n separates ActionText from text used in QUndoStackView - super().__init__(f"Duplicate Group '{group_name}'\nDuplicate Group", parent) + super().__init__(f"Duplicate Group '{group_name}'\n", parent) self._model = model self._group_index = group_index self._new_name = new_name @@ -121,9 +121,7 @@ def __init__( ) -> None: old_name = group_index.data() or "Group" # the \n separates ActionText from text used in QUndoStackView - super().__init__( - f"Rename Group '{old_name}' to '{new_name}'\nRename Group", parent - ) + super().__init__(f"Rename Group '{old_name}' to '{new_name}'\n", parent) self._model = model self._group_index = group_index self._old_name = old_name @@ -150,7 +148,7 @@ def __init__( ) -> None: group_name = group_index.data() or "Group" # the \n separates ActionText from text used in QUndoStackView - super().__init__(f"Add Preset '{name}' to '{group_name}'\nAdd Preset", parent) + super().__init__(f"Add Preset '{name}' to '{group_name}'\n", parent) self._model = model self._group_index = group_index self._name = name @@ -178,9 +176,7 @@ def __init__( preset_name = preset_index.data() or "Preset" group_name = preset_index.parent().data() or "Group" # the \n separates ActionText from text used in QUndoStackView - super().__init__( - f"Remove Preset '{preset_name}' from '{group_name}'\nRemove Preset", parent - ) + super().__init__(f"Remove Preset '{preset_name}' from '{group_name}'\n", parent) self._model = model self._preset_index = preset_index self._group_index = preset_index.parent() @@ -214,7 +210,7 @@ def __init__( ) -> None: preset_name = preset_index.data() or "Preset" # the \n separates ActionText from text used in QUndoStackView - super().__init__(f"Duplicate Preset '{preset_name}'\nDuplicate Preset", parent) + super().__init__(f"Duplicate Preset '{preset_name}'\n", parent) self._model = model self._preset_index = preset_index self._new_name = new_name @@ -245,9 +241,7 @@ def __init__( ) -> None: old_name = preset_index.data() or "Preset" # the \n separates ActionText from text used in QUndoStackView - super().__init__( - f"Rename Preset '{old_name}' to '{new_name}'\nRename Preset", parent - ) + super().__init__(f"Rename Preset '{old_name}' to '{new_name}'\n", parent) self._model = model self._preset_index = preset_index self._old_name = old_name @@ -274,9 +268,7 @@ def __init__( ) -> None: preset_name = preset_index.data() or "Preset" # the \n separates ActionText from text used in QUndoStackView - super().__init__( - f"Update Properties in '{preset_name}'\nUpdate Properties", parent - ) + super().__init__(f"Update Properties in '{preset_name}'\n", parent) self._model = model self._preset_index = preset_index self._new_properties = list(new_properties) @@ -310,7 +302,7 @@ def __init__( ) -> None: preset_name = preset_index.data() or "Preset" # the \n separates ActionText from text used in QUndoStackView - super().__init__(f"Update Settings in '{preset_name}'\nUpdate Settings", parent) + super().__init__(f"Update Settings in '{preset_name}'\n", parent) self._model = model self._preset_index = preset_index self._new_settings = deepcopy(new_settings) @@ -345,9 +337,7 @@ def __init__( preset_index = property_index.parent() preset_name = preset_index.data() or "Preset" # the \n separates ActionText from text used in QUndoStackView - super().__init__( - f"Change Property Value in '{preset_name}'\nChange Property Value", parent - ) + super().__init__(f"Change Property Value in '{preset_name}'\n", parent) self._model = model self._property_index = property_index self._new_value = new_value From 02f0dd5491145b01a73d868974017f91e4ca62d1 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 08:18:28 -0400 Subject: [PATCH 69/70] feat: add name change validation for group and preset renaming; improve error handling --- .../_models/_q_config_model.py | 32 +++++++++++++++++-- .../config_presets/_views/_undo_delegates.py | 28 +++++++++------- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/src/pymmcore_widgets/_models/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py index 3a3643b48..fbb8b153f 100644 --- a/src/pymmcore_widgets/_models/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -144,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 @@ -347,6 +344,7 @@ def removeRows( self.endRemoveRows() return True + # TODO: probably remove the QWidget logic from here def remove( self, idx: QModelIndex, @@ -370,6 +368,34 @@ def remove( 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 # ------------------------------------------------------------------ diff --git a/src/pymmcore_widgets/config_presets/_views/_undo_delegates.py b/src/pymmcore_widgets/config_presets/_views/_undo_delegates.py index f31ce4354..92265e5df 100644 --- a/src/pymmcore_widgets/config_presets/_views/_undo_delegates.py +++ b/src/pymmcore_widgets/config_presets/_views/_undo_delegates.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from qtpy.QtCore import QModelIndex, QObject, Qt -from qtpy.QtWidgets import QStyledItemDelegate, QWidget +from qtpy.QtWidgets import QMessageBox, QStyledItemDelegate, QWidget from pymmcore_widgets._models import QConfigGroupsModel from pymmcore_widgets.device_properties import PropertyWidget @@ -44,16 +44,13 @@ def setModelData( return super().setModelData(editor, model, index) # type: ignore [no-any-return] # Get the new value from the editor - new_value = None - try: - if hasattr(editor, "text"): - new_value = editor.text() - elif hasattr(editor, "currentText"): - new_value = editor.currentText() - elif hasattr(editor, "value"): - new_value = editor.value() - except (AttributeError, TypeError): - pass + new_value: str | None = None + if hasattr(editor, "text"): + new_value = editor.text() + elif hasattr(editor, "currentText"): + new_value = editor.currentText() + elif hasattr(editor, "value"): + new_value = editor.value() if new_value is None: return @@ -63,7 +60,14 @@ def setModelData( if old_value == new_value: return # No change - # Create rename commands for groups/presets + # Validate the name change before creating commands + error_msg = model.is_name_change_valid(index, str(new_value)) + if error_msg: + # Show validation error to user + QMessageBox.warning(None, "Cannot Rename.", error_msg) + return + + # Create rename commands for groups/presets if validation passes node = model._node_from_index(index) if node.is_group: self._undo_stack.push(RenameGroupCommand(model, index, str(new_value))) From 5b5a4d13f143c3f8ad53448b7eafcac66779f9ac Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 7 Jul 2025 12:54:35 -0400 Subject: [PATCH 70/70] fix undo redo --- .../_models/_q_config_model.py | 71 +++++- .../_views/_config_groups_editor.py | 10 +- .../config_presets/_views/_undo_commands.py | 214 +++++++++++++----- 3 files changed, 217 insertions(+), 78 deletions(-) diff --git a/src/pymmcore_widgets/_models/_q_config_model.py b/src/pymmcore_widgets/_models/_q_config_model.py index fbb8b153f..0d400cef6 100644 --- a/src/pymmcore_widgets/_models/_q_config_model.py +++ b/src/pymmcore_widgets/_models/_q_config_model.py @@ -42,6 +42,7 @@ def create_from_core(cls, core: CMMCorePlus) -> Self: def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: super().__init__() + self._in_undo_redo = False # Track when we're in undo/redo operation if groups: self.set_groups(groups) @@ -49,6 +50,14 @@ def __init__(self, groups: Iterable[ConfigGroup] | None = None) -> None: # Required Qt model overrides # ------------------------------------------------------------------ + def set_undo_redo_mode(self, enabled: bool) -> None: + """Set whether we're currently in an undo/redo operation. + + When in undo/redo mode, name uniqueness checks are relaxed to allow + restoration of original names that may temporarily conflict. + """ + self._in_undo_redo = enabled + def columnCount(self, _parent: QModelIndex | None = None) -> int: # In most subclasses, the number of columns is independent of the parent. return len(Col) @@ -143,7 +152,9 @@ def setData( if new_name == node.name or not new_name: return False - if self._name_exists(node.parent, new_name): + # During undo/redo, allow restoration of original names even if they + # temporarily conflict, as the conflict will be resolved by the operation + if not self._in_undo_redo and self._name_exists(node.parent, new_name): return False node.name = new_name @@ -227,11 +238,20 @@ 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.is_channel_group = False # this never gets duplicated - new_grp.name = new_name or self._unique_child_name(self._root, new_grp.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 + 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, QModelIndex(), _payloads=[new_grp]): return self.index(row, 0) @@ -244,7 +264,8 @@ def add_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, suffix="") preset = ConfigPreset(name=name, parent=group_node.payload) @@ -258,16 +279,26 @@ 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. @@ -407,7 +438,8 @@ def update_preset_settings( """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) @@ -431,7 +463,8 @@ def update_preset_properties( """ 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 setting_keys = set(settings) @@ -492,9 +525,8 @@ def get_groups(self) -> list[ConfigGroup]: # 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, @@ -542,10 +574,27 @@ 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): + # Only ensure uniqueness if there would be a conflict AND we're not + # in undo/redo mode + if isinstance(payload, (ConfigGroup, ConfigPreset)): + original_name = payload.name + if not self._in_undo_redo and 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 ---------- diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py index 4e18a83a1..33760ceb0 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_editor.py @@ -549,16 +549,16 @@ def __init__(self, parent: ConfigGroupsEditor) -> None: "Add Preset", parent._add_preset_to_current_group, ) - self.addAction( - StandardIcon.DELETE.icon(), - "Remove", - parent._remove_selected, - ) self.addAction( StandardIcon.COPY.icon(), "Duplicate", parent._duplicate_selected, ) + self.addAction( + StandardIcon.DELETE.icon(), + "Remove", + parent._remove_selected, + ) self.addSeparator() # Undo/Redo actions diff --git a/src/pymmcore_widgets/config_presets/_views/_undo_commands.py b/src/pymmcore_widgets/config_presets/_views/_undo_commands.py index d8f20f2b8..2d828e3ad 100644 --- a/src/pymmcore_widgets/config_presets/_views/_undo_commands.py +++ b/src/pymmcore_widgets/config_presets/_views/_undo_commands.py @@ -1,11 +1,17 @@ -"""Undo/Redo command classes for ConfigGroupsEditor operations.""" +"""Undo/Redo command classes for ConfigGroupsEditor operations. + +IMPORTANT: This implementation uses QPersistentModelIndex instead of QModelIndex +to avoid stale index issues during rapid undo/redo operations. QPersistentModelIndex +automatically updates when the model structure changes, preventing crashes from +accessing invalid nodes. +""" from __future__ import annotations from copy import deepcopy from typing import TYPE_CHECKING, Any -from qtpy.QtCore import QModelIndex +from qtpy.QtCore import QModelIndex, QPersistentModelIndex if TYPE_CHECKING: from collections.abc import Sequence @@ -35,11 +41,13 @@ def __init__( super().__init__(f"Add Group '{name}'\n", parent) self._model = model self._name = name - self._group_index: QModelIndex | None = None + self._group_index: QPersistentModelIndex | None = None def redo(self) -> None: """Execute the add group operation.""" - self._group_index = self._model.add_group(self._name) + index = self._model.add_group(self._name) + if index.isValid(): + self._group_index = QPersistentModelIndex(index) def undo(self) -> None: """Undo the add group operation.""" @@ -60,7 +68,7 @@ def __init__( # the \n separates ActionText from text used in QUndoStackView super().__init__(f"Remove Group '{group_name}'\n", parent) self._model = model - self._group_index = group_index + self._group_index = QPersistentModelIndex(group_index) self._row = group_index.row() self._group_data: ConfigGroup | None = None @@ -68,15 +76,23 @@ def redo(self) -> None: """Execute the remove group operation.""" # Store the group data before removing if self._group_index.isValid(): - self._group_data = deepcopy(self._group_index.data(0x0100)) # UserRole + from qtpy.QtCore import Qt + + self._group_data = deepcopy( + self._group_index.data(Qt.ItemDataRole.UserRole) + ) self._model.removeRows(self._row, 1, QModelIndex()) def undo(self) -> None: """Undo the remove group operation.""" if self._group_data: - self._model.insertRows( - self._row, 1, QModelIndex(), _payloads=[self._group_data] - ) + self._model.set_undo_redo_mode(True) + try: + self._model.insertRows( + self._row, 1, QModelIndex(), _payloads=[self._group_data] + ) + finally: + self._model.set_undo_redo_mode(False) class DuplicateGroupCommand(QUndoCommand): @@ -93,15 +109,18 @@ def __init__( # the \n separates ActionText from text used in QUndoStackView super().__init__(f"Duplicate Group '{group_name}'\n", parent) self._model = model - self._group_index = group_index + self._group_index = QPersistentModelIndex(group_index) self._new_name = new_name - self._new_group_index: QModelIndex | None = None + self._new_group_index: QPersistentModelIndex | None = None def redo(self) -> None: """Execute the duplicate group operation.""" - self._new_group_index = self._model.duplicate_group( - self._group_index, self._new_name - ) + if self._group_index.isValid(): + new_index = self._model.duplicate_group( + QModelIndex(self._group_index), self._new_name + ) + if new_index.isValid(): + self._new_group_index = QPersistentModelIndex(new_index) def undo(self) -> None: """Undo the duplicate group operation.""" @@ -123,17 +142,27 @@ def __init__( # the \n separates ActionText from text used in QUndoStackView super().__init__(f"Rename Group '{old_name}' to '{new_name}'\n", parent) self._model = model - self._group_index = group_index + self._group_index = QPersistentModelIndex(group_index) self._old_name = old_name self._new_name = new_name def redo(self) -> None: """Execute the rename group operation.""" - self._model.setData(self._group_index, self._new_name) + if self._group_index.isValid(): + self._model.set_undo_redo_mode(True) + try: + self._model.setData(QModelIndex(self._group_index), self._new_name) + finally: + self._model.set_undo_redo_mode(False) def undo(self) -> None: """Undo the rename group operation.""" - self._model.setData(self._group_index, self._old_name) + if self._group_index.isValid(): + self._model.set_undo_redo_mode(True) + try: + self._model.setData(QModelIndex(self._group_index), self._old_name) + finally: + self._model.set_undo_redo_mode(False) class AddPresetCommand(QUndoCommand): @@ -150,18 +179,29 @@ def __init__( # the \n separates ActionText from text used in QUndoStackView super().__init__(f"Add Preset '{name}' to '{group_name}'\n", parent) self._model = model - self._group_index = group_index + self._group_index = QPersistentModelIndex(group_index) self._name = name - self._preset_index: QModelIndex | None = None + self._preset_index: QPersistentModelIndex | None = None def redo(self) -> None: """Execute the add preset operation.""" - self._preset_index = self._model.add_preset(self._group_index, self._name) + if self._group_index.isValid(): + preset_index = self._model.add_preset( + QModelIndex(self._group_index), self._name + ) + if preset_index.isValid(): + self._preset_index = QPersistentModelIndex(preset_index) def undo(self) -> None: """Undo the add preset operation.""" - if self._preset_index and self._preset_index.isValid(): - self._model.removeRows(self._preset_index.row(), 1, self._group_index) + if ( + self._preset_index + and self._preset_index.isValid() + and self._group_index.isValid() + ): + self._model.removeRows( + self._preset_index.row(), 1, QModelIndex(self._group_index) + ) class RemovePresetCommand(QUndoCommand): @@ -178,8 +218,8 @@ def __init__( # the \n separates ActionText from text used in QUndoStackView super().__init__(f"Remove Preset '{preset_name}' from '{group_name}'\n", parent) self._model = model - self._preset_index = preset_index - self._group_index = preset_index.parent() + self._preset_index = QPersistentModelIndex(preset_index) + self._group_index = QPersistentModelIndex(preset_index.parent()) self._row = preset_index.row() self._preset_data: ConfigPreset | None = None @@ -187,15 +227,27 @@ def redo(self) -> None: """Execute the remove preset operation.""" # Store the preset data before removing if self._preset_index.isValid(): - self._preset_data = deepcopy(self._preset_index.data(0x0100)) # UserRole - self._model.removeRows(self._row, 1, self._group_index) + from qtpy.QtCore import Qt + + self._preset_data = deepcopy( + self._preset_index.data(Qt.ItemDataRole.UserRole) + ) + if self._group_index.isValid(): + self._model.removeRows(self._row, 1, QModelIndex(self._group_index)) def undo(self) -> None: """Undo the remove preset operation.""" - if self._preset_data: - self._model.insertRows( - self._row, 1, self._group_index, _payloads=[self._preset_data] - ) + if self._preset_data and self._group_index.isValid(): + self._model.set_undo_redo_mode(True) + try: + self._model.insertRows( + self._row, + 1, + QModelIndex(self._group_index), + _payloads=[self._preset_data], + ) + finally: + self._model.set_undo_redo_mode(False) class DuplicatePresetCommand(QUndoCommand): @@ -212,15 +264,18 @@ def __init__( # the \n separates ActionText from text used in QUndoStackView super().__init__(f"Duplicate Preset '{preset_name}'\n", parent) self._model = model - self._preset_index = preset_index + self._preset_index = QPersistentModelIndex(preset_index) self._new_name = new_name - self._new_preset_index: QModelIndex | None = None + self._new_preset_index: QPersistentModelIndex | None = None def redo(self) -> None: """Execute the duplicate preset operation.""" - self._new_preset_index = self._model.duplicate_preset( - self._preset_index, self._new_name - ) + if self._preset_index.isValid(): + new_preset_index = self._model.duplicate_preset( + QModelIndex(self._preset_index), self._new_name + ) + if new_preset_index.isValid(): + self._new_preset_index = QPersistentModelIndex(new_preset_index) def undo(self) -> None: """Undo the duplicate preset operation.""" @@ -243,17 +298,27 @@ def __init__( # the \n separates ActionText from text used in QUndoStackView super().__init__(f"Rename Preset '{old_name}' to '{new_name}'\n", parent) self._model = model - self._preset_index = preset_index + self._preset_index = QPersistentModelIndex(preset_index) self._old_name = old_name self._new_name = new_name def redo(self) -> None: """Execute the rename preset operation.""" - self._model.setData(self._preset_index, self._new_name) + if self._preset_index.isValid(): + self._model.set_undo_redo_mode(True) + try: + self._model.setData(QModelIndex(self._preset_index), self._new_name) + finally: + self._model.set_undo_redo_mode(False) def undo(self) -> None: """Undo the rename preset operation.""" - self._model.setData(self._preset_index, self._old_name) + if self._preset_index.isValid(): + self._model.set_undo_redo_mode(True) + try: + self._model.setData(QModelIndex(self._preset_index), self._old_name) + finally: + self._model.set_undo_redo_mode(False) class UpdatePresetPropertiesCommand(QUndoCommand): @@ -270,7 +335,7 @@ def __init__( # the \n separates ActionText from text used in QUndoStackView super().__init__(f"Update Properties in '{preset_name}'\n", parent) self._model = model - self._preset_index = preset_index + self._preset_index = QPersistentModelIndex(preset_index) self._new_properties = list(new_properties) self._old_settings: list[DevicePropertySetting] | None = None @@ -278,16 +343,23 @@ def redo(self) -> None: """Execute the update preset properties operation.""" # Store the old settings before updating if self._preset_index.isValid(): - preset_data = self._preset_index.data(0x0100) # UserRole + from qtpy.QtCore import Qt + + preset_data = self._preset_index.data(Qt.ItemDataRole.UserRole) if preset_data: self._old_settings = deepcopy(preset_data.settings) - self._model.update_preset_properties(self._preset_index, self._new_properties) + if self._preset_index.isValid(): + self._model.update_preset_properties( + QModelIndex(self._preset_index), self._new_properties + ) def undo(self) -> None: """Undo the update preset properties operation.""" - if self._old_settings is not None: - self._model.update_preset_settings(self._preset_index, self._old_settings) + if self._old_settings is not None and self._preset_index.isValid(): + self._model.update_preset_settings( + QModelIndex(self._preset_index), self._old_settings + ) class UpdatePresetSettingsCommand(QUndoCommand): @@ -304,7 +376,7 @@ def __init__( # the \n separates ActionText from text used in QUndoStackView super().__init__(f"Update Settings in '{preset_name}'\n", parent) self._model = model - self._preset_index = preset_index + self._preset_index = QPersistentModelIndex(preset_index) self._new_settings = deepcopy(new_settings) self._old_settings: list[DevicePropertySetting] | None = None @@ -312,16 +384,23 @@ def redo(self) -> None: """Execute the update preset settings operation.""" # Store the old settings before updating if self._preset_index.isValid(): - preset_data = self._preset_index.data(0x0100) # UserRole + from qtpy.QtCore import Qt + + preset_data = self._preset_index.data(Qt.ItemDataRole.UserRole) if preset_data: self._old_settings = deepcopy(preset_data.settings) - self._model.update_preset_settings(self._preset_index, self._new_settings) + if self._preset_index.isValid(): + self._model.update_preset_settings( + QModelIndex(self._preset_index), self._new_settings + ) def undo(self) -> None: """Undo the update preset settings operation.""" - if self._old_settings is not None: - self._model.update_preset_settings(self._preset_index, self._old_settings) + if self._old_settings is not None and self._preset_index.isValid(): + self._model.update_preset_settings( + QModelIndex(self._preset_index), self._old_settings + ) class ChangePropertyValueCommand(QUndoCommand): @@ -339,7 +418,7 @@ def __init__( # the \n separates ActionText from text used in QUndoStackView super().__init__(f"Change Property Value in '{preset_name}'\n", parent) self._model = model - self._property_index = property_index + self._property_index = QPersistentModelIndex(property_index) self._new_value = new_value self._old_value: Any = None @@ -349,12 +428,13 @@ def redo(self) -> None: if self._property_index.isValid(): self._old_value = self._property_index.data() - self._model.setData(self._property_index, self._new_value) + if self._property_index.isValid(): + self._model.setData(QModelIndex(self._property_index), self._new_value) def undo(self) -> None: """Undo the change property value operation.""" - if self._old_value is not None: - self._model.setData(self._property_index, self._old_value) + if self._old_value is not None and self._property_index.isValid(): + self._model.setData(QModelIndex(self._property_index), self._old_value) class SetChannelGroupCommand(QUndoCommand): @@ -368,15 +448,15 @@ def __init__( ) -> None: if group_index: group_name = group_index.data() or "Group" - super().__init__( - f"Set '{group_name}' as Channel Group\nChange Channel Group", parent - ) + super().__init__(f"Set '{group_name}' as Channel Group\n", parent) else: - super().__init__("Unset Channel Group\nUnset Channel Group", parent) + super().__init__("Unset Channel Group\n", parent) self._model = model - self._new_group_index = group_index - self._old_channel_group_index: QModelIndex | None = None + self._new_group_index = ( + QPersistentModelIndex(group_index) if group_index else None + ) + self._old_channel_group_index: QPersistentModelIndex | None = None def redo(self) -> None: """Execute the set channel group operation.""" @@ -384,17 +464,27 @@ def redo(self) -> None: self._old_channel_group_index = None for i in range(self._model.rowCount()): idx = self._model.index(i, 0) - group_data = idx.data(0x0100) # UserRole + from qtpy.QtCore import Qt + + group_data = idx.data(Qt.ItemDataRole.UserRole) if ( group_data and hasattr(group_data, "is_channel_group") and group_data.is_channel_group ): - self._old_channel_group_index = idx + self._old_channel_group_index = QPersistentModelIndex(idx) break - self._model.set_channel_group(self._new_group_index) + new_index = ( + QModelIndex(self._new_group_index) if self._new_group_index else None + ) + self._model.set_channel_group(new_index) def undo(self) -> None: """Undo the set channel group operation.""" - self._model.set_channel_group(self._old_channel_group_index) + old_index = ( + QModelIndex(self._old_channel_group_index) + if self._old_channel_group_index + else None + ) + self._model.set_channel_group(old_index)

+ Micro-Manager lets you bundle hardware settings into + Groups of Configuration Presets. +