From 7dc7447853013336195724c4d10df290bfc496a5 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 30 Jun 2025 17:52:59 -0400 Subject: [PATCH 1/2] feat: add ConfigGroupsTree --- examples/config_groups_tree.py | 17 ++++++++ src/pymmcore_widgets/__init__.py | 2 + .../config_presets/__init__.py | 2 + .../_qmodel/_property_setting_delegate.py | 40 +++++++++++++++++++ .../config_presets/_views/__init__.py | 0 .../_views/_config_groups_tree.py | 39 ++++++++++++++++++ 6 files changed, 100 insertions(+) create mode 100644 examples/config_groups_tree.py create mode 100644 src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py create mode 100644 src/pymmcore_widgets/config_presets/_views/__init__.py create mode 100644 src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py diff --git a/examples/config_groups_tree.py b/examples/config_groups_tree.py new file mode 100644 index 000000000..09e7694db --- /dev/null +++ b/examples/config_groups_tree.py @@ -0,0 +1,17 @@ +from pymmcore_plus import CMMCorePlus +from qtpy.QtCore import QModelIndex +from qtpy.QtWidgets import QApplication + +from pymmcore_widgets import ConfigGroupsTree + +app = QApplication([]) + +core = CMMCorePlus() +core.loadSystemConfiguration() + +tree = ConfigGroupsTree.create_from_core(core) +tree.show() +tree.expandRecursively(QModelIndex()) +tree.resize(600, 600) + +app.exec() diff --git a/src/pymmcore_widgets/__init__.py b/src/pymmcore_widgets/__init__.py index 0b27cde9d..52bc6b01f 100644 --- a/src/pymmcore_widgets/__init__.py +++ b/src/pymmcore_widgets/__init__.py @@ -14,6 +14,7 @@ "ChannelGroupWidget", "ChannelTable", "ChannelWidget", + "ConfigGroupsTree", "ConfigWizard", "ConfigurationWidget", "CoreLogWidget", @@ -48,6 +49,7 @@ from ._install_widget import InstallWidget from ._log import CoreLogWidget from .config_presets import ( + ConfigGroupsTree, GroupPresetTableWidget, ObjectivesPixelConfigurationWidget, PixelConfigurationWidget, diff --git a/src/pymmcore_widgets/config_presets/__init__.py b/src/pymmcore_widgets/config_presets/__init__.py index ae3db65b5..b2649d15c 100644 --- a/src/pymmcore_widgets/config_presets/__init__.py +++ b/src/pymmcore_widgets/config_presets/__init__.py @@ -4,8 +4,10 @@ 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 __all__ = [ + "ConfigGroupsTree", "GroupPresetTableWidget", "ObjectivesPixelConfigurationWidget", "PixelConfigurationWidget", diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py b/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py new file mode 100644 index 000000000..d97f2883f --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py @@ -0,0 +1,40 @@ +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) diff --git a/src/pymmcore_widgets/config_presets/_views/__init__.py b/src/pymmcore_widgets/config_presets/_views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py b/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py new file mode 100644 index 000000000..9f3146c04 --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtWidgets import QTreeView, QWidget + +from pymmcore_widgets.config_presets._qmodel._config_model import QConfigGroupsModel +from pymmcore_widgets.config_presets._qmodel._property_setting_delegate import ( + PropertySettingDelegate, +) + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + from qtpy.QtCore import QAbstractItemModel + + +class ConfigGroupsTree(QTreeView): + """A tree widget that displays configuration groups.""" + + @classmethod + def create_from_core( + cls, core: CMMCorePlus, parent: QWidget | None = None + ) -> ConfigGroupsTree: + """Create a ConfigGroupsTree 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.setItemDelegateForColumn(2, PropertySettingDelegate(self)) + + def setModel(self, model: QAbstractItemModel | None) -> None: + """Set the model for the tree view.""" + super().setModel(model) + if hh := self.header(): + for col in range(hh.count()): + hh.setSectionResizeMode(col, hh.ResizeMode.Stretch) From 6806b92cd0ef69d054127b2bd9d7976ea20ebbe7 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 30 Jun 2025 19:04:42 -0400 Subject: [PATCH 2/2] starting tests --- .../_views/_config_groups_tree.py | 2 +- .../_property_setting_delegate.py | 13 +++--- tests/test_config_groups_widgets.py | 46 +++++++++++++++++++ 3 files changed, 54 insertions(+), 7 deletions(-) rename src/pymmcore_widgets/config_presets/{_qmodel => _views}/_property_setting_delegate.py (82%) create mode 100644 tests/test_config_groups_widgets.py 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 9f3146c04..1aaa7e804 100644 --- a/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py +++ b/src/pymmcore_widgets/config_presets/_views/_config_groups_tree.py @@ -5,7 +5,7 @@ from qtpy.QtWidgets import QTreeView, QWidget from pymmcore_widgets.config_presets._qmodel._config_model import QConfigGroupsModel -from pymmcore_widgets.config_presets._qmodel._property_setting_delegate import ( +from pymmcore_widgets.config_presets._views._property_setting_delegate import ( PropertySettingDelegate, ) diff --git a/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py b/src/pymmcore_widgets/config_presets/_views/_property_setting_delegate.py similarity index 82% rename from src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py rename to src/pymmcore_widgets/config_presets/_views/_property_setting_delegate.py index d97f2883f..7e0bf25a5 100644 --- a/src/pymmcore_widgets/config_presets/_qmodel/_property_setting_delegate.py +++ b/src/pymmcore_widgets/config_presets/_views/_property_setting_delegate.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pymmcore_plus.model import Setting from qtpy.QtCore import QAbstractItemModel, QModelIndex, Qt from qtpy.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem, QWidget @@ -12,7 +14,7 @@ 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) + 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 @@ -22,11 +24,10 @@ def createEditor( 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): + if isinstance(setting, Setting) and isinstance(editor, PropertyWidget): + editor.setValue(setting.property_value) + else: # pragma: no cover super().setEditorData(editor, index) - return - - editor.setValue(setting.property_value) def setModelData( self, @@ -36,5 +37,5 @@ def setModelData( ) -> None: if model and isinstance(editor, PropertyWidget): model.setData(index, editor.value(), Qt.ItemDataRole.EditRole) - else: + else: # pragma: no cover super().setModelData(editor, model, index) diff --git a/tests/test_config_groups_widgets.py b/tests/test_config_groups_widgets.py new file mode 100644 index 000000000..c6295df09 --- /dev/null +++ b/tests/test_config_groups_widgets.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pymmcore_plus import CMMCorePlus + +from pymmcore_widgets import ConfigGroupsTree +from pymmcore_widgets.config_presets._qmodel._config_model import QConfigGroupsModel +from pymmcore_widgets.config_presets._views._property_setting_delegate import ( + PropertySettingDelegate, +) +from pymmcore_widgets.device_properties._property_widget import PropertyWidget + +if TYPE_CHECKING: + from pytestqt.qtbot import QtBot + + +def test_config_groups_tree(qtbot: QtBot) -> None: + core = CMMCorePlus() + core.loadSystemConfiguration() + tree = ConfigGroupsTree.create_from_core(core) + qtbot.addWidget(tree) + tree.show() + model = tree.model() + assert isinstance(model, QConfigGroupsModel) + + # test the editor delegate ----------------------------- + + delegate = tree.itemDelegateForColumn(2) + assert isinstance(delegate, PropertySettingDelegate) + + setting_value = model.index(0, 2, model.index(0, 0, model.index(0, 0))) + assert model.data(setting_value) == "1" + + # open an editor + tree.edit(setting_value) + editor = tree.focusWidget() + assert isinstance(editor, PropertyWidget) + with qtbot.waitSignal(delegate.commitData): + editor.setValue("2") + + # make sure the model is updated + assert model.data(setting_value) == "2" + group0 = model.get_groups()[0] + preset0 = next(iter(group0.presets.values())) + assert preset0.settings[0].property_value == "2"