Skip to content

Commit 129fa1f

Browse files
authored
refactor: pull in just the model from 451 (#453)
* refactor: pull in just the model from 452 * update test and model * remove dev-prop model * coverage * add back dev dep * fix py3.9 * fix slot * pin pytest-qt * refactor: replace DEVICE_TYPE_ICON with StandardIcon for device type handling and add unit test for StandardIcon * refactor: simplify duplicate_group and duplicate_preset methods; add test for set_channel_group functionality * remove message * refactor: add coverage comments for edge cases in _Node and ConfigGroupPivotModel; add test for get_loaded_devices * test: add validation checks for name changes in QConfigGroupsModel * test: add unit test for updating preset properties in QConfigGroupsModel
1 parent 2a79de5 commit 129fa1f

19 files changed

+1302
-453
lines changed

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Documentation = "https://pymmcore-plus.github.io/pymmcore-widgets"
7272
test = [
7373
"pytest>=8.3.5",
7474
"pytest-cov>=6.1.1",
75-
"pytest-qt>=4.4.0",
75+
"pytest-qt==4.4.0",
7676
"pyyaml>=6.0.2",
7777
"zarr >=2.15,<3",
7878
"numcodecs >0.14.0,<0.16; python_version >= '3.13'",
@@ -85,7 +85,7 @@ dev = [
8585
"mypy>=1.15.0",
8686
"pdbpp>=0.11.6 ; sys_platform != 'win32'",
8787
"pre-commit-uv >=4.1.0",
88-
# "pyqt6>=6.9.0",
88+
"pyqt6>=6.9.0",
8989
"rich>=14.0.0",
9090
"ruff>=0.11.8",
9191
"types-shapely>=2.1.0.20250512",

src/pymmcore_widgets/_icons.py

Lines changed: 82 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,89 @@
11
from __future__ import annotations
22

3+
from enum import Enum
4+
35
from pymmcore_plus import CMMCorePlus, DeviceType
46
from superqt import QIconifyIcon
57

6-
ICONS: dict[DeviceType, str] = {
7-
DeviceType.Any: "mdi:devices",
8-
DeviceType.AutoFocus: "mdi:focus-auto",
9-
DeviceType.Camera: "mdi:camera",
10-
DeviceType.Core: "mdi:heart-cog-outline",
11-
DeviceType.Galvo: "mdi:mirror-variant",
12-
DeviceType.Generic: "mdi:dev-to",
13-
DeviceType.Hub: "mdi:hubspot",
14-
DeviceType.ImageProcessor: "mdi:image-auto-adjust",
15-
DeviceType.Magnifier: "mdi:magnify",
16-
DeviceType.Shutter: "mdi:camera-iris",
17-
DeviceType.SignalIO: "fa6-solid:wave-square",
18-
DeviceType.SLM: "mdi:view-comfy",
19-
DeviceType.Stage: "mdi:arrow-up-down",
20-
DeviceType.State: "mdi:state-machine",
21-
DeviceType.Unknown: "mdi:question-mark-rhombus",
22-
DeviceType.XYStage: "mdi:arrow-all",
23-
DeviceType.Serial: "mdi:serial-port",
24-
}
258

9+
class StandardIcon(str, Enum):
10+
READ_ONLY = "fluent:edit-off-20-regular"
11+
PRE_INIT = "mynaui:letter-p-diamond"
12+
EXPAND = "mdi:expand-horizontal"
13+
COLLAPSE = "mdi:collapse-horizontal"
14+
TABLE = "mdi:table"
15+
TREE = "ph:tree-view"
16+
FOLDER_ADD = "fluent:folder-add-24-regular"
17+
DOCUMENT_ADD = "fluent:document-add-24-regular"
18+
DELETE = "fluent:delete-24-regular"
19+
COPY = "fluent:save-copy-24-regular"
20+
TRANSPOSE = "carbon:transpose"
21+
CONFIG_GROUP = "mdi:folder-settings-variant-outline"
22+
CONFIG_PRESET = "mdi:file-settings-cog-outline"
23+
HELP = "mdi:help-circle-outline"
24+
CHANNEL_GROUP = "mynaui:letter-c-waves-solid"
25+
SYSTEM_GROUP = "mdi:power"
26+
STARTUP = "ic:baseline-power"
27+
SHUTDOWN = "ic:baseline-power-off"
28+
UNDO = "mdi:undo"
29+
REDO = "mdi:redo"
30+
31+
DEVICE_ANY = "mdi:devices"
32+
DEVICE_AUTOFOCUS = "mdi:focus-auto"
33+
DEVICE_CAMERA = "mdi:camera"
34+
DEVICE_CORE = "mdi:heart-cog-outline"
35+
DEVICE_GALVO = "mdi:mirror-variant"
36+
DEVICE_GENERIC = "mdi:dev-to"
37+
DEVICE_HUB = "mdi:hubspot"
38+
DEVICE_IMAGEPROCESSOR = "mdi:image-auto-adjust"
39+
DEVICE_MAGNIFIER = "mdi:magnify"
40+
DEVICE_SHUTTER = "mdi:camera-iris"
41+
DEVICE_SIGNALIO = "fa6-solid:wave-square"
42+
DEVICE_SLM = "mdi:view-comfy"
43+
DEVICE_STAGE = "mdi:arrow-up-down"
44+
DEVICE_STATE = "mdi:state-machine"
45+
DEVICE_UNKNOWN = "mdi:question-mark-rhombus"
46+
DEVICE_XYSTAGE = "mdi:arrow-all"
47+
DEVICE_SERIAL = "mdi:serial-port"
48+
49+
def icon(self, color: str = "gray") -> QIconifyIcon:
50+
return QIconifyIcon(self.value, color=color)
51+
52+
def __str__(self) -> str:
53+
return self.value
2654

27-
def get_device_icon(
28-
device_type_or_name: DeviceType | str, color: str = "gray"
29-
) -> QIconifyIcon | None:
30-
if isinstance(device_type_or_name, str):
31-
try:
32-
device_type = CMMCorePlus.instance().getDeviceType(device_type_or_name)
33-
except Exception:
34-
device_type = DeviceType.Unknown
35-
else:
36-
device_type = device_type_or_name
37-
if icon_string := ICONS.get(device_type):
38-
return QIconifyIcon(icon_string, color=color)
39-
return None
55+
@classmethod
56+
def for_device_type(cls, device_type: DeviceType | str) -> StandardIcon:
57+
"""Return an icon for a specific device type.
58+
59+
If a string is provided, it will be resolved to a DeviceType using the
60+
CMMCorePlus.instance.
61+
"""
62+
if isinstance(device_type, str): # device label
63+
try:
64+
device_type = CMMCorePlus.instance().getDeviceType(device_type)
65+
except Exception: # pragma: no cover
66+
device_type = DeviceType.Unknown
67+
68+
return _DEVICE_TYPE_MAP.get(device_type, StandardIcon.DEVICE_UNKNOWN)
69+
70+
71+
_DEVICE_TYPE_MAP: dict[DeviceType, StandardIcon] = {
72+
DeviceType.Any: StandardIcon.DEVICE_ANY,
73+
DeviceType.AutoFocus: StandardIcon.DEVICE_AUTOFOCUS,
74+
DeviceType.Camera: StandardIcon.DEVICE_CAMERA,
75+
DeviceType.Core: StandardIcon.DEVICE_CORE,
76+
DeviceType.Galvo: StandardIcon.DEVICE_GALVO,
77+
DeviceType.Generic: StandardIcon.DEVICE_GENERIC,
78+
DeviceType.Hub: StandardIcon.DEVICE_HUB,
79+
DeviceType.ImageProcessor: StandardIcon.DEVICE_IMAGEPROCESSOR,
80+
DeviceType.Magnifier: StandardIcon.DEVICE_MAGNIFIER,
81+
DeviceType.Shutter: StandardIcon.DEVICE_SHUTTER,
82+
DeviceType.SignalIO: StandardIcon.DEVICE_SIGNALIO,
83+
DeviceType.SLM: StandardIcon.DEVICE_SLM,
84+
DeviceType.Stage: StandardIcon.DEVICE_STAGE,
85+
DeviceType.State: StandardIcon.DEVICE_STATE,
86+
DeviceType.Unknown: StandardIcon.DEVICE_UNKNOWN,
87+
DeviceType.XYStage: StandardIcon.DEVICE_XYSTAGE,
88+
DeviceType.Serial: StandardIcon.DEVICE_SERIAL,
89+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from ._config_group_pivot_model import ConfigGroupPivotModel
2+
from ._core_functions import (
3+
get_available_devices,
4+
get_config_groups,
5+
get_config_presets,
6+
get_loaded_devices,
7+
get_preset_settings,
8+
get_property_info,
9+
)
10+
from ._py_config_model import (
11+
ConfigGroup,
12+
ConfigPreset,
13+
Device,
14+
DevicePropertySetting,
15+
PixelSizeConfigs,
16+
PixelSizePreset,
17+
)
18+
from ._q_config_model import QConfigGroupsModel
19+
20+
__all__ = [
21+
"ConfigGroup",
22+
"ConfigGroupPivotModel",
23+
"ConfigPreset",
24+
"Device",
25+
"DevicePropertySetting",
26+
"PixelSizeConfigs",
27+
"PixelSizePreset",
28+
"QConfigGroupsModel",
29+
"get_available_devices",
30+
"get_config_groups",
31+
"get_config_presets",
32+
"get_loaded_devices",
33+
"get_preset_settings",
34+
"get_property_info",
35+
]
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
from __future__ import annotations
2+
3+
from typing import overload
4+
5+
from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt
6+
from typing_extensions import Self
7+
8+
from ._py_config_model import ConfigGroup, ConfigPreset, Device, DevicePropertySetting
9+
10+
NULL_INDEX = QModelIndex()
11+
12+
13+
class _Node:
14+
"""Generic tree node that wraps a ConfigGroup, ConfigPreset, or Setting."""
15+
16+
__slots__ = (
17+
"check_state",
18+
"children",
19+
"name",
20+
"parent",
21+
"payload",
22+
)
23+
24+
@classmethod
25+
def create(
26+
cls,
27+
payload: ConfigGroup | ConfigPreset | DevicePropertySetting | Device,
28+
parent: _Node | None = None,
29+
recursive: bool = True,
30+
) -> Self:
31+
"""Create a new _Node with the given name and payload."""
32+
if isinstance(payload, DevicePropertySetting):
33+
name = payload.property_name
34+
elif isinstance(payload, Device):
35+
name = payload.label
36+
else:
37+
name = payload.name
38+
39+
node = cls(name, payload, parent)
40+
if recursive:
41+
if isinstance(payload, ConfigGroup):
42+
for p in payload.presets.values():
43+
node.children.append(_Node.create(p, node))
44+
elif isinstance(payload, ConfigPreset):
45+
for s in payload.settings:
46+
node.children.append(_Node.create(s, node))
47+
elif isinstance(payload, Device):
48+
for prop in payload.properties:
49+
node.children.append(_Node.create(prop, node))
50+
return node
51+
52+
def __init__(
53+
self,
54+
name: str,
55+
payload: ConfigGroup
56+
| ConfigPreset
57+
| DevicePropertySetting
58+
| Device
59+
| None = None,
60+
parent: _Node | None = None,
61+
) -> None:
62+
self.name = name
63+
self.payload = payload
64+
self.parent = parent
65+
self.check_state = Qt.CheckState.Unchecked
66+
self.children: list[_Node] = []
67+
68+
# convenience ------------------------------------------------------------
69+
70+
@property
71+
def siblings(self) -> list[_Node]:
72+
if self.parent is None:
73+
return [] # pragma: no cover
74+
return [x for x in self.parent.children if x is not self]
75+
76+
def num_children(self) -> int:
77+
return len(self.children)
78+
79+
def row_in_parent(self) -> int:
80+
if self.parent is None:
81+
return -1 # pragma: no cover
82+
try:
83+
return self.parent.children.index(self)
84+
except ValueError: # pragma: no cover
85+
return -1
86+
87+
# type helpers -----------------------------------------------------------
88+
89+
@property
90+
def is_group(self) -> bool:
91+
return isinstance(self.payload, ConfigGroup)
92+
93+
@property
94+
def is_preset(self) -> bool:
95+
return isinstance(self.payload, ConfigPreset)
96+
97+
@property
98+
def is_setting(self) -> bool:
99+
return isinstance(self.payload, DevicePropertySetting)
100+
101+
@property
102+
def is_device(self) -> bool:
103+
return isinstance(self.payload, Device)
104+
105+
106+
class _BaseTreeModel(QAbstractItemModel):
107+
"""Thin abstract tree model.
108+
109+
Sub-classes should implement at least the following methods:
110+
111+
* columnCount(self, parent: QModelIndex) -> int: ...
112+
* data(self, index: QModelIndex, role: int = ...) -> Any:
113+
* setData(self, index: QModelIndex, value: Any, role: int) -> bool:
114+
* flags(self, index: QModelIndex) -> Qt.ItemFlag:
115+
"""
116+
117+
def __init__(self, parent: QObject | None = None) -> None:
118+
super().__init__(parent)
119+
self._root = _Node("<root>", None)
120+
121+
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
130+
return self._root
131+
132+
# # ---------- Qt plumbing ----------------------------------------------
133+
134+
def rowCount(self, parent: QModelIndex = NULL_INDEX) -> int:
135+
# Only column 0 should have children in tree models
136+
if parent is not None and parent.isValid() and parent.column() != 0:
137+
return 0
138+
return self._node_from_index(parent).num_children()
139+
140+
def index(
141+
self, row: int, column: int = 0, parent: QModelIndex = NULL_INDEX
142+
) -> QModelIndex:
143+
"""Return the index of the item specified by row, column and parent index."""
144+
parent_node = self._node_from_index(parent)
145+
if 0 <= row < len(parent_node.children):
146+
return self.createIndex(row, column, parent_node.children[row])
147+
return QModelIndex() # pragma: no cover
148+
149+
@overload
150+
def parent(self, child: QModelIndex) -> QModelIndex: ...
151+
@overload
152+
def parent(self) -> QObject | None: ...
153+
def parent(self, child: QModelIndex | None = None) -> QModelIndex | QObject | None:
154+
"""Return the parent of the model item with the given index.
155+
156+
If the item has no parent, an invalid QModelIndex is returned.
157+
"""
158+
if child is None: # pragma: no cover
159+
return None
160+
node = self._node_from_index(child)
161+
if (
162+
node is self._root
163+
or not (parent_node := node.parent)
164+
or parent_node is self._root
165+
):
166+
return QModelIndex()
167+
168+
# A common convention used in models that expose tree data structures is that
169+
# only items in the first column have children.
170+
return self.createIndex(parent_node.row_in_parent(), 0, parent_node)

0 commit comments

Comments
 (0)