Skip to content

Commit 14bde93

Browse files
authored
feat: better config group editor widget (#500)
* wip * use dev prop table * feat: enhance ConfigTreeModel to support settings and update header data * fix init * fix bug * two way updates * feat: add custom delegate for Setting value editing in ConfigTreeModel * refactor: improve duplicate group handling and name validation in ConfigTreeModel * move example * move example * updates * feat: enhance ConfigGroupsEditor with tree view and layout adjustments~ * breaking into two * feat: restructure configuration editor and update device property handling * future import * docs * 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 * wip: preset-table * better table * better table * more improvements * more improvements * starting tests * remove config props * move file * remove file * 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. * feat: Add QConfigGroupsModel and related functionality for configuration management * tmp * wip * wip * 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. * feat: Implement ConfigGroupPivotModel and update related references in the project * fix test * refactor: Consolidate imports and move core functions to a new module * fixes * feat: Enhance device property management by adding QDevicePropertyModel and integrating it into the ConfigGroupsEditor * tmp models * tmp models * add more example * remove * better resizing * 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. * good selection sync * activate buttons * better icons * fix delegate * Enhance ConfigGroupsEditor layout and add persistent editor functionality * good flat * good flat model * wip * less code * moew * refactor: replace FlattenModel with TreeFlatteningProxy for improved tree model handling * 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. * remove flattening * 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 * fix persistent * remove example * fix visibility * refactor: update icon sizes and layout margins in configuration editors * feat: add preset property update functionality and enhance device property selector * refactor: streamline data change handling and improve rebuild logic in ConfigGroupPivotModel * feat: add system group and startup/shutdown preset icons; enhance property handling in models * fix tests * undo redo 1 * undo redo 2 * undo redo 3 * feat: add name change validation for group and preset renaming; improve error handling * fix undo redo * wip * wip * Merge branch 'main' into qconfig-again * lots of changes * prevent use-after-free bug * get rid of local pymmcore-plus install * cleaner node registry, less error prone * don't use closure * refactor: streamline imports and enhance ConfigGroupsEditor functionality - Simplified import of ConfigGroupsEditor in config_groups_editor.py - Added resizing to ConfigGroupsEditor instance - Updated method docstrings for clarity in GroupsPresetFinder - Enhanced device property selector with new actions for checking/unchecking all devices - Improved context menu handling in PropertySettingDelegate * refactor: improve model change handling and streamline toolbar action setup * fix import * tests * fixes * feat: connect tree selection changes to update toolbar actions in ConfigGroupsEditor * feat: implement update preset properties command and enhance undo/redo functionality * feat: enhance undo/redo functionality and improve command structure in ConfigGroupsEditor * feat: update undo/redo actions to be icon-only with tooltips and improve delegate setup for group/preset lists * feat: update edit properties action enabling logic to use current group index * feat: enhance view switching by adding focus and selection sync between column and tree views * feat: enable dynamic configuration updates based on command line argument * feat: improve navigation in ConfigGroupsEditor by expanding tree view on selection * feat: simplify preset list model handling in GroupsPresetFinder * feat: preserve focus during selection changes in ConfigGroupsEditor and ConfigPresetsTableView * before losing persistent editors * feat: enhance property setting editor with control-type classification and improved event handling * feat: refactor PropertySettingDelegate to improve control type handling and painting logic * feat: add property type icons and improve icon handling in device property model * feat: improve table selection synchronization and update transpose action handling * feat: enhance selection behavior in ConfigPresetsTableView for transposed views * feat: enhance multi-selection handling in ConfigPresetsTableView and GroupsPresetFinder * feat: refactor ConfigGroupsEditor to streamline creation and update from core - Updated `create_from_core` method to include `load_configs` parameter for better control over configuration loading. - Modified `update_from_core` method to default `update_configs` to False, enhancing flexibility. - Adjusted test cases to reflect changes in the `ConfigGroupsEditor` initialization and behavior. - Improved test coverage for group and preset operations, including undo functionality and selection synchronization. * feat: update import statements to include GroupsPresetFinder in test_config_groups_widgets * feat: add 'Set Channel Group' action to ConfigEditorToolbar * feat: implement node registry cleanup on row removal and enhance undo delegate functionality * feat: comment out keyPressEvent handling and skip corresponding test * refactor: clean up imports and improve readability in config presets views * feat: implement undo macro context manager for batch undo operations * refactor: simplify view mode toggling and clean up unused code in DevicePropertySelector * refactor: standardize parameters in command constructors for consistency * refactor: improve context menu testing by subclassing QMenu for exec method
1 parent dc48564 commit 14bde93

33 files changed

Lines changed: 5478 additions & 266 deletions

examples/config_groups_editor.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import sys
2+
3+
from pymmcore_plus import CMMCorePlus
4+
from qtpy.QtWidgets import QApplication
5+
6+
from pymmcore_widgets import ConfigGroupsEditor
7+
8+
app = QApplication([])
9+
core = CMMCorePlus()
10+
core.loadSystemConfiguration()
11+
12+
with_cfg = sys.argv[1] in ("1", "true") if len(sys.argv) > 1 else False
13+
cfg = ConfigGroupsEditor.create_from_core(core, load_configs=with_cfg)
14+
cfg.resize(900, 550)
15+
cfg.show()
16+
17+
app.exec()

src/pymmcore_widgets/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"ChannelGroupWidget",
1818
"ChannelTable",
1919
"ChannelWidget",
20+
"ConfigGroupsEditor",
2021
"ConfigGroupsTree",
2122
"ConfigWizard",
2223
"ConfigurationWidget",
@@ -52,6 +53,7 @@
5253
from ._install_widget import InstallWidget
5354
from ._log import CoreLogWidget
5455
from .config_presets import (
56+
ConfigGroupsEditor,
5557
ConfigGroupsTree,
5658
GroupPresetTableWidget,
5759
ObjectivesPixelConfigurationWidget,

src/pymmcore_widgets/_help/__init__.py

Whitespace-only changes.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from pathlib import Path
2+
3+
from qtpy.QtWidgets import (
4+
QDialog,
5+
QDialogButtonBox,
6+
QFrame,
7+
QTextBrowser,
8+
QVBoxLayout,
9+
QWidget,
10+
)
11+
12+
HELP_DOC = Path(__file__).parent / "config_groups_help.html"
13+
14+
15+
class ConfigGroupsHelpDialog(QDialog):
16+
"""Help dialog for the Config Groups Editor."""
17+
18+
def __init__(self, parent: QWidget | None = None):
19+
super().__init__(parent)
20+
self.setWindowTitle("Config Groups and Presets")
21+
self.setModal(True)
22+
23+
# Add help content here
24+
help_text = QTextBrowser(self)
25+
help_text.setReadOnly(True)
26+
help_text.setAcceptRichText(True)
27+
help_text.setHtml(HELP_DOC.read_text())
28+
help_text.setFrameShape(QFrame.Shape.NoFrame)
29+
# enable links
30+
help_text.setOpenExternalLinks(True)
31+
32+
# make the background match the dialog
33+
pal = self.palette()
34+
pal.setColor(pal.ColorRole.Base, pal.color(pal.ColorRole.Window))
35+
help_text.setPalette(pal)
36+
37+
btn_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
38+
btn_box.accepted.connect(self.accept)
39+
40+
layout = QVBoxLayout(self)
41+
layout.addWidget(help_text)
42+
layout.addWidget(btn_box)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Micro-Manager Configuration Groups and Presets - Quick Guide</title>
6+
</head>
7+
<body>
8+
<h1>Configuration Groups and Presets</h1>
9+
10+
<p>
11+
Micro-Manager lets you bundle hardware settings into
12+
<a
13+
href="https://micro-manager.org/Micro-Manager_User%27s_Guide#controlling-devices"
14+
><strong>Groups of Configuration Presets</strong></a
15+
>.
16+
</p>
17+
18+
<p>
19+
Each <strong>Group</strong> contains named <strong>Presets</strong> that
20+
define a set of <strong>Device Property values</strong> to be applied to
21+
the hardware.
22+
</p>
23+
24+
<h2>Typical Workflow</h2>
25+
26+
<ol>
27+
<li>
28+
<strong>Add a configuration group</strong> (e.g. "Channel") using the
29+
<em>Add Group</em> button at the top.
30+
</li>
31+
<li>
32+
<strong>Add a preset to the group</strong> (e.g. "DAPI"), by selecting
33+
the group and clicking the <em>Add Preset</em> button.
34+
</li>
35+
<li>
36+
<strong>Select device properties to include in the preset</strong> (e.g.
37+
"Filter Wheel Position", "Light Source Power") using the device property
38+
selector at the right.
39+
</li>
40+
<li>
41+
<strong>Set the device properties to desired values</strong> for the
42+
currently selected preset in the Presets Table at the bottom.
43+
</li>
44+
<li>
45+
<strong>Repeat steps 2-4 to add more presets to the group</strong> (e.g.
46+
"FITC", "Cy3", "Cy5"). You may <em>Duplicate</em> an existing preset to
47+
copy its device property values if another preset is similar.
48+
</li>
49+
</ol>
50+
51+
<h2>Key Building Blocks</h2>
52+
53+
<h3>1. Devices</h3>
54+
<p>
55+
Physical hardware (camera, filter wheel, stage, light source). Each device
56+
exposes one or more <strong>properties</strong> that can be changed or
57+
read.
58+
</p>
59+
60+
<h3>2. Device Properties</h3>
61+
<p>
62+
A single adjustable parameter on a device (e.g.
63+
<code>Exposure</code>, <code>Position</code>).
64+
</p>
65+
66+
<h3>3. Configuration Presets</h3>
67+
<p>
68+
A preset is a snapshot of <em>specific</em> values for a set of device
69+
<strong>properties</strong>. Activating a preset sets each device property
70+
to its stored value.
71+
</p>
72+
<p>
73+
For example, you might have a <em>FITC</em> preset that sets the
74+
appropriate filter wheel positions and light sources for imaging the FITC
75+
channel.
76+
</p>
77+
78+
<h3>4. Configuration Groups</h3>
79+
<p>
80+
A configuration group is a <strong>collection of presets</strong>. It is
81+
customary (<em>but not mandatory</em>) for each preset in a group to share
82+
the same set of device properties.
83+
</p>
84+
<p>
85+
For example, you might have a <em>Channel</em> group that includes presets
86+
for each optical configuration preset (<em>DAPI</em>, <em>FITC</em>,
87+
<em>Cy3</em>, <em>Cy5</em>).
88+
</p>
89+
</body>
90+
</html>

src/pymmcore_widgets/_icons.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from enum import Enum
44

5-
from pymmcore_plus import CMMCorePlus, DeviceType
5+
from pymmcore_plus import CMMCorePlus, DeviceType, PropertyType
66
from superqt import QIconifyIcon
77

88

@@ -15,6 +15,7 @@ class StandardIcon(str, Enum):
1515
TREE = "ph:tree-view"
1616
FOLDER_ADD = "fluent:folder-add-24-regular"
1717
DOCUMENT_ADD = "fluent:document-add-24-regular"
18+
PROPERTY_ADD = "fluent:form-new-24-regular"
1819
DELETE = "fluent:delete-24-regular"
1920
COPY = "fluent:save-copy-24-regular"
2021
TRANSPOSE = "carbon:transpose"
@@ -28,6 +29,13 @@ class StandardIcon(str, Enum):
2829
UNDO = "mdi:undo"
2930
REDO = "mdi:redo"
3031

32+
PROP_STRING = "mdi:alphabetical-variant"
33+
PROP_INTEGER = "mdi:numeric"
34+
PROP_FLOAT = "mdi:decimal"
35+
PROP_ENUM = "mdi:format-list-bulleted"
36+
PROP_BOOLEAN = "mdi:toggle-switch-outline"
37+
PROP_UNDEF = "mdi:help-circle-outline"
38+
3139
DEVICE_ANY = "mdi:devices"
3240
DEVICE_AUTOFOCUS = "mdi:focus-auto"
3341
DEVICE_CAMERA = "mdi:camera"
@@ -67,6 +75,28 @@ def for_device_type(cls, device_type: DeviceType | str) -> StandardIcon:
6775

6876
return _DEVICE_TYPE_MAP.get(device_type, StandardIcon.DEVICE_UNKNOWN)
6977

78+
@classmethod
79+
def for_property_type(
80+
cls,
81+
prop_type: PropertyType,
82+
allowed: tuple[str, ...] = (),
83+
) -> StandardIcon:
84+
"""Return an icon for a specific property type."""
85+
if prop_type is PropertyType.Integer and set(allowed) == {"0", "1"}:
86+
return StandardIcon.PROP_BOOLEAN
87+
if allowed:
88+
return StandardIcon.PROP_ENUM
89+
return _PROPERTY_TYPE_MAP.get(prop_type, StandardIcon.PROP_UNDEF)
90+
91+
92+
_PROPERTY_TYPE_MAP: dict[PropertyType, StandardIcon] = {
93+
PropertyType.String: StandardIcon.PROP_STRING,
94+
PropertyType.Integer: StandardIcon.PROP_INTEGER,
95+
PropertyType.Float: StandardIcon.PROP_FLOAT,
96+
PropertyType.Enum: StandardIcon.PROP_ENUM,
97+
PropertyType.Boolean: StandardIcon.PROP_BOOLEAN,
98+
PropertyType.Undef: StandardIcon.PROP_UNDEF,
99+
}
70100

71101
_DEVICE_TYPE_MAP: dict[DeviceType, StandardIcon] = {
72102
DeviceType.Any: StandardIcon.DEVICE_ANY,

src/pymmcore_widgets/_log.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def __init__(
122122
mmcore: CMMCorePlus | None = None,
123123
) -> None:
124124
super().__init__(parent)
125-
self._mmcore = mmcore or CMMCorePlus().instance()
125+
self._mmcore = mmcore or CMMCorePlus.instance()
126126
self.setWindowTitle("Log Console")
127127

128128
# --- Log path ---

src/pymmcore_widgets/_models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
PixelSizePreset,
1717
)
1818
from ._q_config_model import QConfigGroupsModel
19+
from ._q_device_prop_model import QDevicePropertyModel
1920

2021
__all__ = [
2122
"ConfigGroup",
@@ -26,6 +27,7 @@
2627
"PixelSizeConfigs",
2728
"PixelSizePreset",
2829
"QConfigGroupsModel",
30+
"QDevicePropertyModel",
2931
"get_available_devices",
3032
"get_config_groups",
3133
"get_config_presets",

src/pymmcore_widgets/_models/_base_tree_model.py

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import itertools
34
from typing import overload
45

56
from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt
@@ -9,11 +10,14 @@
910

1011
NULL_INDEX = QModelIndex()
1112

13+
_node_id_counter = itertools.count()
14+
1215

1316
class _Node:
1417
"""Generic tree node that wraps a ConfigGroup, ConfigPreset, or Setting."""
1518

1619
__slots__ = (
20+
"_id",
1721
"check_state",
1822
"children",
1923
"name",
@@ -59,6 +63,7 @@ def __init__(
5963
| None = None,
6064
parent: _Node | None = None,
6165
) -> None:
66+
self._id: int = next(_node_id_counter)
6267
self.name = name
6368
self.payload = payload
6469
self.parent = parent
@@ -104,7 +109,12 @@ def is_device(self) -> bool:
104109

105110

106111
class _BaseTreeModel(QAbstractItemModel):
107-
"""Thin abstract tree model.
112+
"""Thin abstract tree model using integer IDs for createIndex.
113+
114+
Uses the integer-ID pattern: each _Node gets a unique int ID, stored in a
115+
registry dict. createIndex() receives the int (not the Python object), so
116+
stale QModelIndex / QPersistentModelIndex objects resolve to a safe dict
117+
lookup instead of a dangling void* pointer.
108118
109119
Sub-classes should implement at least the following methods:
110120
@@ -117,16 +127,36 @@ class _BaseTreeModel(QAbstractItemModel):
117127
def __init__(self, parent: QObject | None = None) -> None:
118128
super().__init__(parent)
119129
self._root = _Node("<root>", None)
130+
self._node_registry: dict[int, _Node] = {}
131+
132+
def _create_node(
133+
self,
134+
payload: ConfigGroup | ConfigPreset | DevicePropertySetting | Device,
135+
parent: _Node | None = None,
136+
recursive: bool = True,
137+
) -> _Node:
138+
"""Create a _Node, register it and all descendants, and return it."""
139+
node = _Node.create(payload, parent, recursive=recursive)
140+
self._register_tree(node)
141+
return node
142+
143+
def _register_tree(self, node: _Node) -> None:
144+
"""Register *node* and all its descendants in the node registry."""
145+
self._node_registry[node._id] = node
146+
for child in node.children:
147+
self._register_tree(child)
148+
149+
def _unregister_tree(self, node: _Node) -> None:
150+
"""Remove *node* and all its descendants from the node registry."""
151+
self._node_registry.pop(node._id, None)
152+
for child in node.children:
153+
self._unregister_tree(child)
120154

121155
def _node_from_index(self, index: QModelIndex | None) -> _Node:
122-
if (
123-
index
124-
and index.isValid()
125-
and isinstance((node := index.internalPointer()), _Node)
126-
):
127-
# return the node if index is valid
128-
return node
129-
# otherwise return the root node
156+
if index and index.isValid():
157+
node = self._node_registry.get(index.internalId())
158+
if node is not None:
159+
return node
130160
return self._root
131161

132162
# # ---------- Qt plumbing ----------------------------------------------
@@ -143,7 +173,8 @@ def index(
143173
"""Return the index of the item specified by row, column and parent index."""
144174
parent_node = self._node_from_index(parent)
145175
if 0 <= row < len(parent_node.children):
146-
return self.createIndex(row, column, parent_node.children[row])
176+
child = parent_node.children[row]
177+
return self.createIndex(row, column, child._id)
147178
return QModelIndex() # pragma: no cover
148179

149180
@overload
@@ -167,4 +198,4 @@ def parent(self, child: QModelIndex | None = None) -> QModelIndex | QObject | No
167198

168199
# A common convention used in models that expose tree data structures is that
169200
# only items in the first column have children.
170-
return self.createIndex(parent_node.row_in_parent(), 0, parent_node)
201+
return self.createIndex(parent_node.row_in_parent(), 0, parent_node._id)

0 commit comments

Comments
 (0)