diff --git a/examples/config_groups_editor.py b/examples/config_groups_editor.py new file mode 100644 index 000000000..bb29d68e8 --- /dev/null +++ b/examples/config_groups_editor.py @@ -0,0 +1,27 @@ +from pymmcore_plus import CMMCorePlus +from qtpy.QtCore import QModelIndex +from qtpy.QtWidgets import QApplication, QSplitter + +from pymmcore_widgets import ConfigGroupsEditor, ConfigGroupsTree + +app = QApplication([]) +core = CMMCorePlus() +core.loadSystemConfiguration() + +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() + +app.exec() diff --git a/src/pymmcore_widgets/__init__.py b/src/pymmcore_widgets/__init__.py index 52bc6b01f..fbf6a83e3 100644 --- a/src/pymmcore_widgets/__init__.py +++ b/src/pymmcore_widgets/__init__.py @@ -14,6 +14,7 @@ "ChannelGroupWidget", "ChannelTable", "ChannelWidget", + "ConfigGroupsEditor", "ConfigGroupsTree", "ConfigWizard", "ConfigurationWidget", @@ -49,6 +50,7 @@ from ._install_widget import InstallWidget from ._log import CoreLogWidget from .config_presets import ( + ConfigGroupsEditor, ConfigGroupsTree, GroupPresetTableWidget, ObjectivesPixelConfigurationWidget, 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/_views/_config_views.py b/src/pymmcore_widgets/config_presets/_views/_config_views.py new file mode 100644 index 000000000..ef75990be --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_views/_config_views.py @@ -0,0 +1,467 @@ +from __future__ import annotations + +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, + QHBoxLayout, + QLabel, + QListView, + QSplitter, + QToolBar, + QVBoxLayout, + QWidget, +) +from superqt import QIconifyIcon + +from pymmcore_widgets._icons import ICONS +from pymmcore_widgets.config_presets._qmodel._config_model import ( + QConfigGroupsModel, + _Node, +) +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 pymmcore_plus.model import ConfigPreset, Setting + from PyQt6.QtGui import QAction, QActionGroup +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: + 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) + 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[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 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) 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", +] diff --git a/src/pymmcore_widgets/device_properties/_device_property_table.py b/src/pymmcore_widgets/device_properties/_device_property_table.py index 57ee3673c..2030c03b8 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,123 @@ 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_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.""" + """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_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. + 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_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 + 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_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()) + 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 - def getCheckedProperties(self) -> list[tuple[str, str, str]]: + self.showRow(row) + + 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). """ # 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 + and (not visible_only or not self.isRowHidden(row)) + ): + 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)