Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Documentation = "https://pymmcore-plus.github.io/pymmcore-widgets"
test = [
"pytest>=8.3.5",
"pytest-cov>=6.1.1",
"pytest-qt>=4.4.0",
"pytest-qt==4.4.0",
"pyyaml>=6.0.2",
"zarr >=2.15,<3",
"numcodecs >0.14.0,<0.16; python_version >= '3.13'",
Expand All @@ -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",
Expand Down
114 changes: 82 additions & 32 deletions src/pymmcore_widgets/_icons.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,89 @@
from __future__ import annotations

from enum import Enum

from pymmcore_plus import CMMCorePlus, DeviceType
from superqt import QIconifyIcon

ICONS: dict[DeviceType, str] = {
DeviceType.Any: "mdi:devices",
DeviceType.AutoFocus: "mdi:focus-auto",
DeviceType.Camera: "mdi:camera",
DeviceType.Core: "mdi:heart-cog-outline",
DeviceType.Galvo: "mdi:mirror-variant",
DeviceType.Generic: "mdi:dev-to",
DeviceType.Hub: "mdi:hubspot",
DeviceType.ImageProcessor: "mdi:image-auto-adjust",
DeviceType.Magnifier: "mdi:magnify",
DeviceType.Shutter: "mdi:camera-iris",
DeviceType.SignalIO: "fa6-solid:wave-square",
DeviceType.SLM: "mdi:view-comfy",
DeviceType.Stage: "mdi:arrow-up-down",
DeviceType.State: "mdi:state-machine",
DeviceType.Unknown: "mdi:question-mark-rhombus",
DeviceType.XYStage: "mdi:arrow-all",
DeviceType.Serial: "mdi:serial-port",
}

class StandardIcon(str, Enum):
READ_ONLY = "fluent:edit-off-20-regular"
PRE_INIT = "mynaui:letter-p-diamond"
EXPAND = "mdi:expand-horizontal"
COLLAPSE = "mdi:collapse-horizontal"
TABLE = "mdi:table"
TREE = "ph:tree-view"
FOLDER_ADD = "fluent:folder-add-24-regular"
DOCUMENT_ADD = "fluent:document-add-24-regular"
DELETE = "fluent:delete-24-regular"
COPY = "fluent:save-copy-24-regular"
TRANSPOSE = "carbon:transpose"
CONFIG_GROUP = "mdi:folder-settings-variant-outline"
CONFIG_PRESET = "mdi:file-settings-cog-outline"
HELP = "mdi:help-circle-outline"
CHANNEL_GROUP = "mynaui:letter-c-waves-solid"
SYSTEM_GROUP = "mdi:power"
STARTUP = "ic:baseline-power"
SHUTDOWN = "ic:baseline-power-off"
UNDO = "mdi:undo"
REDO = "mdi:redo"

DEVICE_ANY = "mdi:devices"
DEVICE_AUTOFOCUS = "mdi:focus-auto"
DEVICE_CAMERA = "mdi:camera"
DEVICE_CORE = "mdi:heart-cog-outline"
DEVICE_GALVO = "mdi:mirror-variant"
DEVICE_GENERIC = "mdi:dev-to"
DEVICE_HUB = "mdi:hubspot"
DEVICE_IMAGEPROCESSOR = "mdi:image-auto-adjust"
DEVICE_MAGNIFIER = "mdi:magnify"
DEVICE_SHUTTER = "mdi:camera-iris"
DEVICE_SIGNALIO = "fa6-solid:wave-square"
DEVICE_SLM = "mdi:view-comfy"
DEVICE_STAGE = "mdi:arrow-up-down"
DEVICE_STATE = "mdi:state-machine"
DEVICE_UNKNOWN = "mdi:question-mark-rhombus"
DEVICE_XYSTAGE = "mdi:arrow-all"
DEVICE_SERIAL = "mdi:serial-port"

def icon(self, color: str = "gray") -> QIconifyIcon:
return QIconifyIcon(self.value, color=color)

def __str__(self) -> str:
return self.value

def get_device_icon(
device_type_or_name: DeviceType | str, color: str = "gray"
) -> QIconifyIcon | None:
if isinstance(device_type_or_name, str):
try:
device_type = CMMCorePlus.instance().getDeviceType(device_type_or_name)
except Exception:
device_type = DeviceType.Unknown
else:
device_type = device_type_or_name
if icon_string := ICONS.get(device_type):
return QIconifyIcon(icon_string, color=color)
return None
@classmethod
def for_device_type(cls, device_type: DeviceType | str) -> StandardIcon:
"""Return an icon for a specific device type.

If a string is provided, it will be resolved to a DeviceType using the
CMMCorePlus.instance.
"""
if isinstance(device_type, str): # device label
try:
device_type = CMMCorePlus.instance().getDeviceType(device_type)
except Exception: # pragma: no cover
device_type = DeviceType.Unknown

return _DEVICE_TYPE_MAP.get(device_type, StandardIcon.DEVICE_UNKNOWN)


_DEVICE_TYPE_MAP: dict[DeviceType, StandardIcon] = {
DeviceType.Any: StandardIcon.DEVICE_ANY,
DeviceType.AutoFocus: StandardIcon.DEVICE_AUTOFOCUS,
DeviceType.Camera: StandardIcon.DEVICE_CAMERA,
DeviceType.Core: StandardIcon.DEVICE_CORE,
DeviceType.Galvo: StandardIcon.DEVICE_GALVO,
DeviceType.Generic: StandardIcon.DEVICE_GENERIC,
DeviceType.Hub: StandardIcon.DEVICE_HUB,
DeviceType.ImageProcessor: StandardIcon.DEVICE_IMAGEPROCESSOR,
DeviceType.Magnifier: StandardIcon.DEVICE_MAGNIFIER,
DeviceType.Shutter: StandardIcon.DEVICE_SHUTTER,
DeviceType.SignalIO: StandardIcon.DEVICE_SIGNALIO,
DeviceType.SLM: StandardIcon.DEVICE_SLM,
DeviceType.Stage: StandardIcon.DEVICE_STAGE,
DeviceType.State: StandardIcon.DEVICE_STATE,
DeviceType.Unknown: StandardIcon.DEVICE_UNKNOWN,
DeviceType.XYStage: StandardIcon.DEVICE_XYSTAGE,
DeviceType.Serial: StandardIcon.DEVICE_SERIAL,
}
35 changes: 35 additions & 0 deletions src/pymmcore_widgets/_models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from ._config_group_pivot_model import ConfigGroupPivotModel
from ._core_functions import (
get_available_devices,
get_config_groups,
get_config_presets,
get_loaded_devices,
get_preset_settings,
get_property_info,
)
from ._py_config_model import (
ConfigGroup,
ConfigPreset,
Device,
DevicePropertySetting,
PixelSizeConfigs,
PixelSizePreset,
)
from ._q_config_model import QConfigGroupsModel

__all__ = [
"ConfigGroup",
"ConfigGroupPivotModel",
"ConfigPreset",
"Device",
"DevicePropertySetting",
"PixelSizeConfigs",
"PixelSizePreset",
"QConfigGroupsModel",
"get_available_devices",
"get_config_groups",
"get_config_presets",
"get_loaded_devices",
"get_preset_settings",
"get_property_info",
]
170 changes: 170 additions & 0 deletions src/pymmcore_widgets/_models/_base_tree_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
from __future__ import annotations

from typing import overload

from qtpy.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt
from typing_extensions import Self

from ._py_config_model import ConfigGroup, ConfigPreset, Device, DevicePropertySetting

NULL_INDEX = QModelIndex()


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

__slots__ = (
"check_state",
"children",
"name",
"parent",
"payload",
)

@classmethod
def create(
cls,
payload: ConfigGroup | ConfigPreset | DevicePropertySetting | Device,
parent: _Node | None = None,
recursive: bool = True,
) -> Self:
"""Create a new _Node with the given name and payload."""
if isinstance(payload, DevicePropertySetting):
name = payload.property_name
elif isinstance(payload, Device):
name = payload.label

Check warning on line 35 in src/pymmcore_widgets/_models/_base_tree_model.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/_models/_base_tree_model.py#L35

Added line #L35 was not covered by tests
else:
name = payload.name

node = cls(name, payload, parent)
if recursive:
if isinstance(payload, ConfigGroup):
for p in payload.presets.values():
node.children.append(_Node.create(p, node))
elif isinstance(payload, ConfigPreset):
for s in payload.settings:
node.children.append(_Node.create(s, node))
elif isinstance(payload, Device):
for prop in payload.properties:
node.children.append(_Node.create(prop, node))

Check warning on line 49 in src/pymmcore_widgets/_models/_base_tree_model.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/_models/_base_tree_model.py#L48-L49

Added lines #L48 - L49 were not covered by tests
return node

def __init__(
self,
name: str,
payload: ConfigGroup
| ConfigPreset
| DevicePropertySetting
| Device
| None = None,
parent: _Node | None = None,
) -> None:
self.name = name
self.payload = payload
self.parent = parent
self.check_state = Qt.CheckState.Unchecked
self.children: list[_Node] = []

# convenience ------------------------------------------------------------

@property
def siblings(self) -> list[_Node]:
if self.parent is None:
return [] # pragma: no cover
return [x for x in self.parent.children if x is not self]

def num_children(self) -> int:
return len(self.children)

def row_in_parent(self) -> int:
if self.parent is None:
return -1 # pragma: no cover
try:
return self.parent.children.index(self)
except ValueError: # pragma: no cover
return -1

# type helpers -----------------------------------------------------------

@property
def is_group(self) -> bool:
return isinstance(self.payload, ConfigGroup)

@property
def is_preset(self) -> bool:
return isinstance(self.payload, ConfigPreset)

@property
def is_setting(self) -> bool:
return isinstance(self.payload, DevicePropertySetting)

@property
def is_device(self) -> bool:
return isinstance(self.payload, Device)

Check warning on line 103 in src/pymmcore_widgets/_models/_base_tree_model.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/_models/_base_tree_model.py#L103

Added line #L103 was not covered by tests


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("<root>", 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)
Loading