From 581906e681f49084428fea176dfaa34cc36a0704 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Sat, 13 Jul 2024 10:13:59 -0400 Subject: [PATCH 01/38] fix: WellPlateWidget initial drawing (#327) * fix: wip * test: update * fix: plate init --- src/pymmcore_widgets/useq_widgets/_well_plate_widget.py | 3 +-- tests/useq_widgets/test_plate_widget.py | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py index 029032896..bccc66555 100644 --- a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py +++ b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py @@ -112,8 +112,7 @@ def __init__( self.plate_name.currentTextChanged.connect(self._on_plate_name_changed) self._show_rotation.toggled.connect(self._on_show_rotation_toggled) - if plan: - self.setValue(plan) + self.setValue(plan or self.value()) # _________________________PUBLIC METHODS_________________________ # diff --git a/tests/useq_widgets/test_plate_widget.py b/tests/useq_widgets/test_plate_widget.py index 0f125762d..0d8f5613c 100644 --- a/tests/useq_widgets/test_plate_widget.py +++ b/tests/useq_widgets/test_plate_widget.py @@ -47,6 +47,11 @@ def test_plate_widget_selection(qtbot: QtBot) -> None: wdg = WellPlateWidget() qtbot.addWidget(wdg) wdg.show() + + # Ensure that if no plate is provided when instantiating the widget, the currently + # selected plate in the combobox is used. + assert wdg._view.scene().items() + wdg.plate_name.setCurrentText("96-well") wdg.setCurrentSelection((slice(0, 4, 2), (1, 2))) selection = wdg.currentSelection() From 5957860901237424a297096b4817e80f30927605 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Sat, 13 Jul 2024 12:18:00 -0400 Subject: [PATCH 02/38] fix: make name editable EditGroupWidget (#328) fix: edit group name --- .../_group_preset_widget/_edit_group_widget.py | 10 ++++++++-- tests/test_group_preset_widget.py | 9 +++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/pymmcore_widgets/_group_preset_widget/_edit_group_widget.py b/src/pymmcore_widgets/_group_preset_widget/_edit_group_widget.py index 1ba16c199..7100f90a0 100644 --- a/src/pymmcore_widgets/_group_preset_widget/_edit_group_widget.py +++ b/src/pymmcore_widgets/_group_preset_widget/_edit_group_widget.py @@ -35,7 +35,6 @@ def __init__(self, group: str, *, parent: QWidget | None = None) -> None: self._create_gui() self.group_lineedit.setText(self._group) - self.group_lineedit.setEnabled(False) self.destroyed.connect(self._disconnect) @@ -144,6 +143,13 @@ def _update_filter(self) -> None: ) def _add_group(self) -> None: + # rename group if it has been changed + renamed: bool = False + if self._group != self.group_lineedit.text(): + self._mmc.renameConfigGroup(self._group, self.group_lineedit.text()) + self._group = self.group_lineedit.text() + renamed = True + # [(device, property, value), ...], need to remove the value new_dev_prop = [x[:2] for x in self._prop_table.getCheckedProperties()] @@ -152,7 +158,7 @@ def _add_group(self) -> None: (k[0], k[1]) for k in self._mmc.getConfigData(self._group, presets[0]) ] - if preset_dev_prop == new_dev_prop: + if preset_dev_prop == new_dev_prop and not renamed: return # get any new dev prop to add to each preset diff --git a/tests/test_group_preset_widget.py b/tests/test_group_preset_widget.py index feb45b254..3d6b625cb 100644 --- a/tests/test_group_preset_widget.py +++ b/tests/test_group_preset_widget.py @@ -165,10 +165,15 @@ def test_edit_group(global_mmcore: CMMCorePlus, qtbot: QtBot): item.setCheckState(Qt.CheckState.Checked) assert table.item(t_row, 0).text() == "Camera-CCDTemperature" + edit_gp.group_lineedit.setText("Camera_New") + edit_gp.modify_group_btn.click() - assert edit_gp.info_lbl.text() == "'Camera' Group Modified." + assert edit_gp.info_lbl.text() == "'Camera_New' Group Modified." + + assert "Camera" not in mmc.getAvailableConfigGroups() + assert "Camera_New" in mmc.getAvailableConfigGroups() - dp = [k[:2] for k in mmc.getConfigData("Camera", "LowRes")] + dp = [k[:2] for k in mmc.getConfigData("Camera_New", "LowRes")] assert ("Camera", "CCDTemperature") in dp From cd4351c5bc309e7f9be444b9c6a53f5a1f3d8609 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 17 Jul 2024 10:34:55 -0400 Subject: [PATCH 03/38] style: fix pixel affine table (#341) --- src/pymmcore_widgets/_pixel_configuration_widget.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pymmcore_widgets/_pixel_configuration_widget.py b/src/pymmcore_widgets/_pixel_configuration_widget.py index ed8d2d105..ebfebd4b3 100644 --- a/src/pymmcore_widgets/_pixel_configuration_widget.py +++ b/src/pymmcore_widgets/_pixel_configuration_widget.py @@ -82,11 +82,11 @@ def __init__( left_layout.setContentsMargins(0, 0, 0, 0) left_layout.setSpacing(5) self._px_table = _PixelTable() - affine_lbl = QLabel("Affine Transformations:") + affine_lbl = QLabel("Affine Transformation:") self._affine_table = AffineTable() - left_layout.addWidget(self._px_table) - left_layout.addWidget(affine_lbl) - left_layout.addWidget(self._affine_table) + left_layout.addWidget(self._px_table, 1) + left_layout.addWidget(affine_lbl, 0) + left_layout.addWidget(self._affine_table, 0) self._props_selector = _PropertySelector(mmcore=self._mmc) @@ -483,7 +483,8 @@ def __init__(self, parent: QWidget | None = None): self._add_table_spinboxes() self.setValue(DEFAULT_AFFINE) - self.setMaximumHeight(self.minimumSizeHint().height()) + def sizeHint(self) -> Any: + return self.minimumSizeHint() def _add_table_spinboxes(self) -> None: """Add a spinbox in each cell of the table.""" From 7ec66e575dd35fcbd8cb2386cc6fa443d3c28964 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Wed, 17 Jul 2024 14:04:07 -0500 Subject: [PATCH 04/38] style: Manually compute sizeHint() (#343) --- src/pymmcore_widgets/_pixel_configuration_widget.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pymmcore_widgets/_pixel_configuration_widget.py b/src/pymmcore_widgets/_pixel_configuration_widget.py index ebfebd4b3..aa341b894 100644 --- a/src/pymmcore_widgets/_pixel_configuration_widget.py +++ b/src/pymmcore_widgets/_pixel_configuration_widget.py @@ -473,7 +473,7 @@ def __init__(self, parent: QWidget | None = None): self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.horizontalHeader().setVisible(False) - self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.verticalHeader().setDefaultSectionSize(20) self.verticalHeader().setVisible(False) self.setColumnCount(3) @@ -484,7 +484,10 @@ def __init__(self, parent: QWidget | None = None): self.setValue(DEFAULT_AFFINE) def sizeHint(self) -> Any: - return self.minimumSizeHint() + sz = self.minimumSizeHint() + rc = self.rowCount() + sz.setHeight(self.rowHeight(0) * rc + (rc - 1)) + return sz def _add_table_spinboxes(self) -> None: """Add a spinbox in each cell of the table.""" From a7a916e1b6696f6f92b002e5395f0a8236176de7 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Thu, 18 Jul 2024 13:34:05 -0500 Subject: [PATCH 05/38] style: clarify save/load buttons in MDAWidget (#346) --- src/pymmcore_widgets/useq_widgets/_mda_sequence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pymmcore_widgets/useq_widgets/_mda_sequence.py b/src/pymmcore_widgets/useq_widgets/_mda_sequence.py index 852997ed7..7522649fd 100644 --- a/src/pymmcore_widgets/useq_widgets/_mda_sequence.py +++ b/src/pymmcore_widgets/useq_widgets/_mda_sequence.py @@ -297,10 +297,10 @@ def __init__( ) self._duration_label.setWordWrap(True) - self._save_button = QPushButton("Save") + self._save_button = QPushButton("Save Settings") self._save_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) self._save_button.clicked.connect(self.save) - self._load_button = QPushButton("Load") + self._load_button = QPushButton("Load Settings") self._load_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) self._load_button.clicked.connect(self.load) From 8e52e0fa0756f15a899b5b535a4f38167879cbfc Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Thu, 18 Jul 2024 13:35:19 -0500 Subject: [PATCH 06/38] style: unfill radio buttions in GridPlanWidget (#344) --- src/pymmcore_widgets/useq_widgets/_grid.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 44664faf1..2af6690be 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -200,6 +200,12 @@ def __init__(self, parent: QWidget | None = None): self.order.currentIndexChanged.connect(self._on_change) self.relative_to.currentIndexChanged.connect(self._on_change) + # FIXME: On Windows 11, buttons within an inner widget of a ScrollArea + # are filled in with the accent color, making it very difficult to see + # which radio button is checked. This HACK solves the issue. It's + # likely future Qt versions will fix this. + inner_widget.setStyleSheet("QRadioButton {color: none}") + # ------------------------- Public API ------------------------- def mode(self) -> Mode: From 8b5d1e6203fa133133528fa9bac59a307357b354 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Thu, 18 Jul 2024 13:36:48 -0500 Subject: [PATCH 07/38] fix: Align spin boxes and labels in GridPlan (#345) --- src/pymmcore_widgets/mda/_xy_bounds.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pymmcore_widgets/mda/_xy_bounds.py b/src/pymmcore_widgets/mda/_xy_bounds.py index ef42bca7d..b51aadd95 100644 --- a/src/pymmcore_widgets/mda/_xy_bounds.py +++ b/src/pymmcore_widgets/mda/_xy_bounds.py @@ -142,9 +142,9 @@ def __init__( QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow ) values_layout.addRow("Top:", self.top_edit) - values_layout.addRow("Left:", self.bottom_edit) - values_layout.addRow("Right:", self.left_edit) - values_layout.addRow("Bottom:", self.right_edit) + values_layout.addRow("Left:", self.left_edit) + values_layout.addRow("Right:", self.right_edit) + values_layout.addRow("Bottom:", self.bottom_edit) top_layout = QHBoxLayout() top_layout.setContentsMargins(0, 0, 0, 0) From e1fc7cd17dca0d271f5d67b2a73d5725d974166c Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Thu, 18 Jul 2024 13:38:03 -0500 Subject: [PATCH 08/38] fix: Only allow YAML save/load when YAML available (#347) --- src/pymmcore_widgets/useq_widgets/_mda_sequence.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/pymmcore_widgets/useq_widgets/_mda_sequence.py b/src/pymmcore_widgets/useq_widgets/_mda_sequence.py index 7522649fd..0f147493b 100644 --- a/src/pymmcore_widgets/useq_widgets/_mda_sequence.py +++ b/src/pymmcore_widgets/useq_widgets/_mda_sequence.py @@ -1,5 +1,6 @@ from __future__ import annotations +from importlib.util import find_spec from itertools import permutations from pathlib import Path from typing import cast @@ -439,7 +440,7 @@ def save(self, file: str | Path | None = None) -> None: self, "Save MDASequence and filename.", "", - "All (*.yaml *yml *json);;YAML (*.yaml *.yml);;JSON (*.json)", + self._settings_extensions(), ) if not file: # pragma: no cover return @@ -467,7 +468,7 @@ def load(self, file: str | Path | None = None) -> None: self, "Select an MDAsequence file.", "", - "All (*.yaml *yml *json);;YAML (*.yaml *.yml);;JSON (*.json)", + self._settings_extensions(), ) if not file: # pragma: no cover return @@ -485,6 +486,14 @@ def load(self, file: str | Path | None = None) -> None: # -------------- Private API -------------- + def _settings_extensions(self) -> str: + """Returns the available extensions for MDA settings save/load.""" + if find_spec("yaml") is not None: + # YAML available + return "All (*.yaml *yml *.json);;YAML (*.yaml *.yml);;JSON (*.json)" + # Only JSON + return "All (*.json);;JSON (*.json)" + def _on_af_toggled(self, checked: bool) -> None: # if the 'af_per_position' checkbox in the PositionTable is checked, set checked # also the autofocus p axis checkbox. From 53e9ddfe4331ca245330635a7c099391378a9dcd Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Thu, 18 Jul 2024 14:50:51 -0400 Subject: [PATCH 09/38] fix: update the GroupPresetTableWidget policy (#330) * fix: table policy * fix: resizeColumnToContents --- .../_group_preset_widget/_group_preset_table_widget.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/_group_preset_widget/_group_preset_table_widget.py b/src/pymmcore_widgets/_group_preset_widget/_group_preset_table_widget.py index e188140f0..3024d65e8 100644 --- a/src/pymmcore_widgets/_group_preset_widget/_group_preset_table_widget.py +++ b/src/pymmcore_widgets/_group_preset_widget/_group_preset_table_widget.py @@ -5,6 +5,7 @@ from pymmcore_plus import CMMCorePlus from qtpy.QtCore import Qt from qtpy.QtWidgets import ( + QAbstractScrollArea, QFileDialog, QGroupBox, QHBoxLayout, @@ -38,12 +39,13 @@ class _MainTable(QTableWidget): def __init__(self) -> None: super().__init__() hdr = self.horizontalHeader() - hdr.setSectionResizeMode(hdr.ResizeMode.Stretch) + hdr.setStretchLastSection(True) hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignHCenter) vh = self.verticalHeader() vh.setVisible(False) vh.setSectionResizeMode(vh.ResizeMode.Fixed) vh.setDefaultSectionSize(24) + self.setSizeAdjustPolicy(QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents) self.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.setColumnCount(2) @@ -248,6 +250,9 @@ def _populate_table(self) -> None: elif isinstance(wdg, PropertyWidget): wdg = wdg._value_widget # type: ignore + # resize to contents the table + self.table_wdg.resizeColumnToContents(0) + def _get_cfg_data(self, group: str, preset: str) -> tuple[str, str, str, int]: # Return last device-property-value for the preset and the # total number of device-property-value included in the preset. From 013635ed531f3fd7cf9bc615ef78e41310a1d81a Mon Sep 17 00:00:00 2001 From: Willi L Stepp <52414717+wl-stepp@users.noreply.github.com> Date: Fri, 19 Jul 2024 15:27:22 +0000 Subject: [PATCH 10/38] refactor: split run mda in mda widget (#350) --- src/pymmcore_widgets/mda/_core_mda.py | 35 ++++++++++++++++++++------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/pymmcore_widgets/mda/_core_mda.py b/src/pymmcore_widgets/mda/_core_mda.py index 44397b38e..18e7e71b2 100644 --- a/src/pymmcore_widgets/mda/_core_mda.py +++ b/src/pymmcore_widgets/mda/_core_mda.py @@ -183,8 +183,18 @@ def get_next_available_path(self, requested_path: Path) -> Path: """ return get_next_available_path(requested_path=requested_path) - def run_mda(self) -> None: - """Run the MDA sequence experiment.""" + def prepare_mda(self) -> bool | str | Path | None: + """Prepare the MDA sequence experiment. + + Returns + ------- + bool + False if MDA to be cancelled due to autofocus issue. + str | Path + Preparation successful, save path to be used for saving and saving active + None + Preparation successful, saving deactivated + """ # in case the user does not press enter after editing the save name. self.save_info.save_name.editingFinished.emit() @@ -197,20 +207,27 @@ def run_mda(self) -> None: and (not self.tab_wdg.isChecked(pos) or not pos.af_per_position.isChecked()) and not self._confirm_af_intentions() ): - return - - sequence = self.value() + return False # technically, this is in the metadata as well, but isChecked is more direct if self.save_info.isChecked(): - save_path = self._update_save_path_from_metadata( - sequence, update_metadata=True + return self._update_save_path_from_metadata( + self.value(), update_metadata=True ) else: - save_path = None + return None + def execute_mda(self, output: Path | str | object | None) -> None: + """Execute the MDA experiment corresponding to the current value.""" + sequence = self.value() # run the MDA experiment asynchronously - self._mmc.run_mda(sequence, output=save_path) + self._mmc.run_mda(sequence, output=output) + + def run_mda(self) -> None: + save_path = self.prepare_mda() + if save_path is False: + return + self.execute_mda(save_path) # ------------------- private Methods ---------------------- From 95117e778af0e197dfe19b7ccab5e6d07fb6a735 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 21 Jul 2024 16:28:28 -0400 Subject: [PATCH 11/38] fix: fix valueChanged signals on PropertyWidget (#352) --- src/pymmcore_widgets/_property_widget.py | 21 ++++++++++++--------- tests/test_prop_widget.py | 13 +++++++++++-- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/pymmcore_widgets/_property_widget.py b/src/pymmcore_widgets/_property_widget.py index 72c0e5a29..59c94feb8 100644 --- a/src/pymmcore_widgets/_property_widget.py +++ b/src/pymmcore_widgets/_property_widget.py @@ -336,10 +336,12 @@ def __init__( self.layout().addWidget(cast(QWidget, self._value_widget)) self.destroyed.connect(self._disconnect) - def _try_update_from_core(self) -> None: + def _try_update_from_core(self) -> Any: # set current value from core, ignoring errors + value = "" with contextlib.suppress(RuntimeError, ValueError): - self._value_widget.setValue(self._mmc.getProperty(*self._dp)) + value = self._mmc.getProperty(*self._dp) + self._value_widget.setValue(value) # disable for any device init state besides 0 (Uninitialized) if hasattr(self._mmc, "getDeviceInitializationState") and ( @@ -347,6 +349,7 @@ def _try_update_from_core(self) -> None: and self._mmc.getDeviceInitializationState(self._device_label) ): self.setDisabled(True) + return value # connect events and queue for disconnection on widget destroyed def _on_core_change(self, dev_label: str, prop_name: str, new_val: Any) -> None: @@ -355,13 +358,13 @@ def _on_core_change(self, dev_label: str, prop_name: str, new_val: Any) -> None: self._value_widget.setValue(new_val) def _on_value_widget_change(self, value: Any) -> None: - if not self._updates_core: - return - try: - self._mmc.setProperty(self._device_label, self._prop_name, value) - except (RuntimeError, ValueError): - # if there's an error when updating mmcore, reset widget value to mmcore - self._try_update_from_core() + if self._updates_core: + try: + self._mmc.setProperty(self._device_label, self._prop_name, value) + except (RuntimeError, ValueError): + # if there's an error when updating mmcore, reset widget value to mmcore + value = self._try_update_from_core() + self.valueChanged.emit(value) def _disconnect(self) -> None: with contextlib.suppress(RuntimeError): diff --git a/tests/test_prop_widget.py b/tests/test_prop_widget.py index 127127f55..240e69b3a 100644 --- a/tests/test_prop_widget.py +++ b/tests/test_prop_widget.py @@ -32,7 +32,7 @@ def _assert_equal(a, b): @pytest.mark.parametrize("dev, prop", dev_props) -def test_property_widget(dev, prop, qtbot): +def test_property_widget(dev, prop, qtbot) -> None: wdg = PropertyWidget(dev, prop, mmcore=CORE) qtbot.addWidget(wdg) if CORE.isPropertyReadOnly(dev, prop) or prop in ( @@ -83,7 +83,16 @@ def test_property_widget(dev, prop, qtbot): _assert_equal(wdg.value(), start_val) -def test_reset(global_mmcore, qtbot): +def test_prop_widget_signals(global_mmcore: CMMCorePlus, qtbot): + wdg = PropertyWidget("Camera", "Binning", connect_core=False) + qtbot.addWidget(wdg) + assert wdg.value() == "1" + with qtbot.waitSignal(wdg.valueChanged, timeout=1000): + wdg._value_widget.setValue(2) + assert wdg.value() == "2" + + +def test_reset(global_mmcore: CMMCorePlus, qtbot) -> None: wdg = PropertyWidget("Camera", "Binning", mmcore=global_mmcore) qtbot.addWidget(wdg) global_mmcore.loadSystemConfiguration() From b1d67b87225fa6ab436b897f16b1e8616a2bca42 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Mon, 22 Jul 2024 14:17:02 -0500 Subject: [PATCH 12/38] feat: Refactor GridPlanWidget (#351) * feat: Refactor GridPlanWidget * Use radio buttons to toggle stack * Revert bottom_stuff to separate widget * Minor fixes * Fix tests --- src/pymmcore_widgets/mda/_core_grid.py | 18 +- src/pymmcore_widgets/useq_widgets/_grid.py | 397 ++++++++++++--------- 2 files changed, 234 insertions(+), 181 deletions(-) diff --git a/src/pymmcore_widgets/mda/_core_grid.py b/src/pymmcore_widgets/mda/_core_grid.py index 35f282b30..a30c0cf71 100644 --- a/src/pymmcore_widgets/mda/_core_grid.py +++ b/src/pymmcore_widgets/mda/_core_grid.py @@ -4,7 +4,7 @@ from pymmcore_plus import CMMCorePlus -from pymmcore_widgets.useq_widgets._grid import GridPlanWidget, Mode +from pymmcore_widgets.useq_widgets._grid import GridPlanWidget from ._xy_bounds import CoreXYBoundsControl @@ -33,7 +33,6 @@ def __init__( self._mmc = mmcore or CMMCorePlus.instance() self._core_xy_bounds = CoreXYBoundsControl(core=self._mmc) - self._core_xy_bounds.setEnabled(False) # replace GridPlanWidget attributes with CoreXYBoundsControl attributes so we # can use the same super() methods. self.top = self._core_xy_bounds.top_edit @@ -42,18 +41,9 @@ def __init__( self.bottom = self._core_xy_bounds.bottom_edit # replace the lrtb_wdg from the parent widget with the core_xy_bounds widget - self.bounds_layout.addWidget(self._core_xy_bounds, 1) - self.lrtb_wdg.hide() - - # this is required to toggle the enabled/disabled state of our new xy_bounds - # widget when the radio buttons in the parent widget change. - self.mode_groups[Mode.BOUNDS] = ( - self._core_xy_bounds, - self.top, - self.left, - self.right, - self.bottom, - ) + self._bounds_wdg.bounds_layout.removeWidget(self._bounds_wdg.lrtb_wdg) + self._bounds_wdg.bounds_layout.insertRow(0, self._core_xy_bounds) + self._bounds_wdg.lrtb_wdg.hide() # connect self.top.valueChanged.connect(self._on_change) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 2af6690be..8282b4e08 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import Enum -from typing import Literal, Sequence, cast +from typing import Literal import useq from qtpy.QtCore import QSize, Qt, Signal @@ -10,12 +10,13 @@ QButtonGroup, QDoubleSpinBox, QFormLayout, - QGridLayout, QHBoxLayout, QLabel, QRadioButton, QScrollArea, + QSizePolicy, QSpinBox, + QStackedWidget, QVBoxLayout, QWidget, ) @@ -45,6 +46,9 @@ class Mode(Enum): AREA = "area" BOUNDS = "bounds" + def __str__(self) -> str: + return self.value + class GridPlanWidget(QScrollArea): """Widget to edit a [`useq-schema` GridPlan](https://pymmcore-plus.github.io/useq-schema/schema/axes/#grid-plans).""" @@ -57,122 +61,41 @@ def __init__(self, parent: QWidget | None = None): self._fov_width: float | None = None self._fov_height: float | None = None - self.rows = QSpinBox() - self.rows.setRange(1, 1000) - self.rows.setValue(1) - self.rows.setSuffix(" fields") - self.columns = QSpinBox() - self.columns.setRange(1, 1000) - self.columns.setValue(1) - self.columns.setSuffix(" fields") - - self.area_width = QDoubleSpinBox() - self.area_width.setRange(0.01, 100) - self.area_width.setDecimals(2) - # here for area_width and area_height we are using mm instead of µm because - # (as in GridWidthHeight) because it is probably easier for a user to define - # the area in mm - self.area_width.setSuffix(" mm") - self.area_width.setSingleStep(0.1) - self.area_height = QDoubleSpinBox() - self.area_height.setRange(0.01, 100) - self.area_height.setDecimals(2) - self.area_height.setSuffix(" mm") - self.area_height.setSingleStep(0.1) - - self.left = QDoubleSpinBox() - self.left.setRange(-10000, 10000) - self.left.setValue(0) - self.left.setDecimals(3) - self.left.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons) - self.top = QDoubleSpinBox() - self.top.setRange(-10000, 10000) - self.top.setValue(0) - self.top.setDecimals(3) - self.top.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons) - self.right = QDoubleSpinBox() - self.right.setRange(-10000, 10000) - self.right.setValue(0) - self.right.setDecimals(3) - self.right.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons) - self.bottom = QDoubleSpinBox() - self.bottom.setRange(-10000, 10000) - self.bottom.setValue(0) - self.bottom.setDecimals(3) - self.bottom.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons) - - self.overlap = QDoubleSpinBox() - self.overlap.setRange(-1000, 1000) - self.overlap.setValue(0) - self.overlap.setSuffix(" %") - - self.order = QEnumComboBox(self, OrderMode) - self.relative_to = QEnumComboBox(self, RelativeTo) - self.order.currentEnum() - self._mode_number_radio = QRadioButton() + self._mode_number_radio.setText("Fields of View") self._mode_area_radio = QRadioButton() + self._mode_area_radio.setText("Width && Height") self._mode_bounds_radio = QRadioButton() + self._mode_bounds_radio.setText("Absolute Bounds") self._mode_btn_group = QButtonGroup() self._mode_btn_group.addButton(self._mode_number_radio) self._mode_btn_group.addButton(self._mode_area_radio) self._mode_btn_group.addButton(self._mode_bounds_radio) self._mode_btn_group.buttonToggled.connect(self.setMode) - row_col_layout = QHBoxLayout() - row_col_layout.addWidget(self._mode_number_radio) - row_col_layout.addWidget(QLabel("Rows:")) - row_col_layout.addWidget(self.rows, 1) - row_col_layout.addWidget(QLabel("Cols:")) - row_col_layout.addWidget(self.columns, 1) + top_layout = QHBoxLayout() + top_layout.addWidget(QLabel("Create Grid Using:")) + top_layout.addWidget(self._mode_number_radio) + top_layout.addWidget(self._mode_area_radio) + top_layout.addWidget(self._mode_bounds_radio) - width_height_layout = QHBoxLayout() - width_height_layout.addWidget(self._mode_area_radio) - width_height_layout.addWidget(QLabel("Width:")) - width_height_layout.addWidget(self.area_width, 1) - width_height_layout.addWidget(QLabel("Height:")) - width_height_layout.addWidget(self.area_height, 1) - - self.lrtb_wdg = QWidget() - lrtb_grid = QGridLayout(self.lrtb_wdg) - lrtb_grid.setContentsMargins(0, 0, 0, 0) - lrtb_grid.addWidget(QLabel("Left:"), 0, 0, Qt.AlignmentFlag.AlignRight) - lrtb_grid.addWidget(self.left, 0, 1) - lrtb_grid.addWidget(QLabel("Top:"), 0, 2, Qt.AlignmentFlag.AlignRight) - lrtb_grid.addWidget(self.top, 0, 3) - lrtb_grid.addWidget(QLabel("Right:"), 1, 0, Qt.AlignmentFlag.AlignRight) - lrtb_grid.addWidget(self.right, 1, 1) - lrtb_grid.addWidget(QLabel("Bottom:"), 1, 2, Qt.AlignmentFlag.AlignRight) - lrtb_grid.addWidget(self.bottom, 1, 3) - lrtb_grid.setColumnStretch(1, 1) - lrtb_grid.setColumnStretch(3, 1) - - self.bounds_layout = QHBoxLayout() - self.bounds_layout.addWidget(self._mode_bounds_radio) - self.bounds_layout.addWidget(self.lrtb_wdg, 1) - - bottom_stuff = QHBoxLayout() - - bot_left = QFormLayout() - bot_left.setFieldGrowthPolicy( - QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow - ) - bot_left.addRow("Overlap:", self.overlap) - bot_left.addRow("Order:", self.order) - bot_left.addRow("Relative to:", self.relative_to) + self.stack = _ResizableStackedWidget(self) + self._row_col_wdg = _RowsColsWidget() + self.stack.addWidget(self._row_col_wdg) + self._width_height_wdg = _WidthHeightWidget() + self.stack.addWidget(self._width_height_wdg) + self._bounds_wdg = _BoundsWidget() + self.stack.addWidget(self._bounds_wdg) - bottom_stuff.addLayout(bot_left) + self._bottom_stuff = _BottomStuff() # wrap the whole thing in an inner widget so we can put it in this ScrollArea inner_widget = QWidget(self) layout = QVBoxLayout(inner_widget) - layout.addLayout(row_col_layout) - layout.addWidget(SeparatorWidget()) - layout.addLayout(width_height_layout) # hiding until useq supports it - layout.addWidget(SeparatorWidget()) - layout.addLayout(self.bounds_layout) + layout.addLayout(top_layout) layout.addWidget(SeparatorWidget()) - layout.addLayout(bottom_stuff) + layout.addWidget(self.stack) + layout.addWidget(self._bottom_stuff) layout.addStretch() self.setWidget(inner_widget) @@ -180,25 +103,7 @@ def __init__(self, parent: QWidget | None = None): self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.mode_groups: dict[Mode, Sequence[QWidget]] = { - Mode.NUMBER: (self.rows, self.columns), - Mode.AREA: (self.area_width, self.area_height), - Mode.BOUNDS: (self.left, self.top, self.right, self.bottom), - } - - self.setMode(Mode.NUMBER) - - self.top.valueChanged.connect(self._on_change) - self.bottom.valueChanged.connect(self._on_change) - self.left.valueChanged.connect(self._on_change) - self.right.valueChanged.connect(self._on_change) - self.rows.valueChanged.connect(self._on_change) - self.columns.valueChanged.connect(self._on_change) - self.area_width.valueChanged.connect(self._on_change) - self.area_height.valueChanged.connect(self._on_change) - self.overlap.valueChanged.connect(self._on_change) - self.order.currentIndexChanged.connect(self._on_change) - self.relative_to.currentIndexChanged.connect(self._on_change) + self._mode_number_radio.setChecked(True) # FIXME: On Windows 11, buttons within an inner widget of a ScrollArea # are filled in with the accent color, making it very difficult to see @@ -212,42 +117,33 @@ def mode(self) -> Mode: """Return the current mode, one of "number", "area", or "bounds".""" return self._mode - def setMode( - self, mode: Mode | Literal["number", "area", "bounds"] | None = None - ) -> None: + def setMode(self, mode: Mode | Literal["number", "area", "bounds"]) -> None: """Set the current mode, one of "number", "area", or "bounds". Parameters ---------- - mode : Mode | Literal["number", "area", "bounds"] | None, optional + mode : Mode | Literal["number", "area", "bounds"] The mode to set. - (If None, the mode is determined by the sender().data(), for internal usage) """ - btn = None btn_map: dict[QAbstractButton, Mode] = { self._mode_number_radio: Mode.NUMBER, self._mode_area_radio: Mode.AREA, self._mode_bounds_radio: Mode.BOUNDS, } - if isinstance(mode, QRadioButton): - btn = cast("QRadioButton", mode) - elif mode is None: # use sender if mode is None - sender = cast("QButtonGroup", self.sender()) - btn = sender.checkedButton() - if btn is not None: - _mode: Mode = btn_map[btn] - else: - _mode = Mode(mode) - {v: k for k, v in btn_map.items()}[_mode].setChecked(True) + if isinstance(mode, str): + mode = Mode(mode) + elif isinstance(mode, QRadioButton): + mode = btn_map[mode] - previous, self._mode = getattr(self, "_mode", None), _mode + previous, self._mode = getattr(self, "_mode", None), mode if previous != self._mode: - for group, members in self.mode_groups.items(): - for member in members: - member.setEnabled(_mode == group) - self.relative_to.setEnabled(_mode != Mode.BOUNDS) + for i, m in enumerate(Mode): + if mode == m: + self.stack.setCurrentIndex(i) self._on_change() + self._bottom_stuff.setMode(mode) + def value(self) -> useq.GridFromEdges | useq.GridRowsColumns | useq.GridWidthHeight: """Return the current value of the widget as a [`useq-schema` GridPlan](https://pymmcore-plus.github.io/useq-schema/schema/axes/#grid-plans). @@ -257,36 +153,36 @@ def value(self) -> useq.GridFromEdges | useq.GridRowsColumns | useq.GridWidthHei The current [GridPlan](https://pymmcore-plus.github.io/useq-schema/schema/axes/#grid-plans) value of the widget. """ - over = self.overlap.value() - _order = cast("OrderMode", self.order.currentEnum()) + over = self._bottom_stuff.overlap.value() + order = self._bottom_stuff.order.currentEnum() common = { "overlap": (over, over), - "mode": _order.value, + "mode": order.value, "fov_width": self._fov_width, "fov_height": self._fov_height, } if self._mode == Mode.NUMBER: return useq.GridRowsColumns( - rows=self.rows.value(), - columns=self.columns.value(), - relative_to=cast("RelativeTo", self.relative_to.currentEnum()).value, + rows=self._row_col_wdg.rows.value(), + columns=self._row_col_wdg.columns.value(), + relative_to=self._bottom_stuff.relative_to.currentEnum().value, **common, ) elif self._mode == Mode.BOUNDS: return useq.GridFromEdges( - top=self.top.value(), - left=self.left.value(), - bottom=self.bottom.value(), - right=self.right.value(), + top=self._bounds_wdg.top.value(), + left=self._bounds_wdg.left.value(), + bottom=self._bounds_wdg.bottom.value(), + right=self._bounds_wdg.right.value(), **common, ) elif self._mode == Mode.AREA: # converting width and height to microns because GridWidthHeight expects µm return useq.GridWidthHeight( - width=self.area_width.value() * 1000, - height=self.area_height.value() * 1000, - relative_to=cast("RelativeTo", self.relative_to.currentEnum()).value, + width=self._width_height_wdg.area_width.value() * 1000, + height=self._width_height_wdg.area_height.value() * 1000, + relative_to=self._bottom_stuff.relative_to.currentEnum().value, **common, ) raise NotImplementedError @@ -302,20 +198,20 @@ def setValue(self, value: useq.GridFromEdges | useq.GridRowsColumns) -> None: """ with signals_blocked(self): if isinstance(value, useq.GridRowsColumns): - self.rows.setValue(value.rows) - self.columns.setValue(value.columns) - self.relative_to.setCurrentText(value.relative_to.value) + self._row_col_wdg.rows.setValue(value.rows) + self._row_col_wdg.columns.setValue(value.columns) + self._bottom_stuff.relative_to.setCurrentText(value.relative_to.value) elif isinstance(value, useq.GridFromEdges): - self.top.setValue(value.top) - self.left.setValue(value.left) - self.bottom.setValue(value.bottom) - self.right.setValue(value.right) + self._bounds_wdg.top.setValue(value.top) + self._bounds_wdg.left.setValue(value.left) + self._bounds_wdg.bottom.setValue(value.bottom) + self._bounds_wdg.right.setValue(value.right) elif isinstance(value, useq.GridWidthHeight): # GridWidthHeight width and height are expressed in µm but this widget # uses mm, so we convert width and height to mm here - self.area_width.setValue(value.width / 1000) - self.area_height.setValue(value.height / 1000) - self.relative_to.setCurrentText(value.relative_to.value) + self._width_height_wdg.area_width.setValue(value.width / 1000) + self._width_height_wdg.area_height.setValue(value.height / 1000) + self._bottom_stuff.relative_to.setCurrentText(value.relative_to.value) else: # pragma: no cover raise TypeError(f"Expected useq grid plan, got {type(value)}") @@ -325,9 +221,9 @@ def setValue(self, value: useq.GridFromEdges | useq.GridRowsColumns) -> None: self._fov_width = value.fov_width if value.overlap: - self.overlap.setValue(value.overlap[0]) + self._bottom_stuff.overlap.setValue(value.overlap[0]) - self.order.setCurrentEnum(OrderMode(value.mode.value)) + self._bottom_stuff.order.setCurrentEnum(OrderMode(value.mode.value)) mode = { useq.GridRowsColumns: Mode.NUMBER, @@ -368,3 +264,170 @@ def _on_change(self) -> None: if (val := self.value()) is None: return # pragma: no cover self.valueChanged.emit(val) + + +class _RowsColsWidget(QWidget): + valueChanged = Signal() + + def __init__(self) -> None: + super().__init__() + + self.rows = QSpinBox() + self.rows.setRange(1, 1000) + self.rows.setValue(1) + self.rows.setSuffix(" fields") + self.columns = QSpinBox() + self.columns.setRange(1, 1000) + self.columns.setValue(1) + self.columns.setSuffix(" fields") + + layout = QFormLayout(self) + layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + layout.addRow("Grid Rows:", self.rows) + layout.addRow("Grid Cols:", self.columns) + + self.rows.valueChanged.connect(self.valueChanged) + self.columns.valueChanged.connect(self.valueChanged) + + +class _WidthHeightWidget(QWidget): + valueChanged = Signal() + + def __init__(self) -> None: + super().__init__() + + self.area_width = QDoubleSpinBox() + self.area_width.setRange(0.01, 100) + self.area_width.setDecimals(2) + # here for area_width and area_height we are using mm instead of µm because + # (as in GridWidthHeight) because it is probably easier for a user to define + # the area in mm + self.area_width.setSuffix(" mm") + self.area_width.setSingleStep(0.1) + self.area_height = QDoubleSpinBox() + self.area_height.setRange(0.01, 100) + self.area_height.setDecimals(2) + self.area_height.setSuffix(" mm") + self.area_height.setSingleStep(0.1) + + layout = QFormLayout(self) + layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + layout.addRow("Width:", self.area_width) + layout.addRow("Height:", self.area_height) + + self.area_width.valueChanged.connect(self.valueChanged) + self.area_height.valueChanged.connect(self.valueChanged) + + +class _BoundsWidget(QWidget): + valueChanged = Signal() + + def __init__(self) -> None: + super().__init__() + + self.left = QDoubleSpinBox() + self.left.setRange(-10000, 10000) + self.left.setValue(0) + self.left.setDecimals(3) + self.left.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons) + self.top = QDoubleSpinBox() + self.top.setRange(-10000, 10000) + self.top.setValue(0) + self.top.setDecimals(3) + self.top.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons) + self.right = QDoubleSpinBox() + self.right.setRange(-10000, 10000) + self.right.setValue(0) + self.right.setDecimals(3) + self.right.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons) + self.bottom = QDoubleSpinBox() + self.bottom.setRange(-10000, 10000) + self.bottom.setValue(0) + self.bottom.setDecimals(3) + self.bottom.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons) + + self.lrtb_wdg = QWidget() + lrtb_layout = QFormLayout(self.lrtb_wdg) + lrtb_layout.setContentsMargins(12, 0, 12, 12) + lrtb_layout.addRow("Left:", self.left) + lrtb_layout.addRow("Top:", self.top) + lrtb_layout.addRow("Right:", self.right) + lrtb_layout.addRow("Bottom:", self.bottom) + + self.bounds_layout = QFormLayout(self) + self.bounds_layout.addWidget(self.lrtb_wdg) + + self.top.valueChanged.connect(self.valueChanged) + self.bottom.valueChanged.connect(self.valueChanged) + self.left.valueChanged.connect(self.valueChanged) + self.right.valueChanged.connect(self.valueChanged) + + +class _ResizableStackedWidget(QStackedWidget): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent=parent) + self.currentChanged.connect(self.onCurrentChanged) + + def addWidget(self, wdg: QWidget | None) -> int: + if wdg is not None: + wdg.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) + return super().addWidget(wdg) # type: ignore + + def onCurrentChanged(self, idx: int) -> None: + for i in range(self.count()): + wdg = self.widget(i) + if wdg is None: + continue + if i == idx: + wdg.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + else: + wdg.setSizePolicy( + QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored + ) + wdg.adjustSize() + self.adjustSize() + + +class _BottomStuff(QWidget): + valueChanged = Signal() + + def __init__(self) -> None: + super().__init__() + + self.overlap_lbl = QLabel("Overlap:") + self.overlap = QDoubleSpinBox() + self.overlap.setRange(-1000, 1000) + self.overlap.setValue(0) + self.overlap.setSuffix(" %") + self.order_lbl = QLabel("Acquisition order:") + self.order = QEnumComboBox(self, OrderMode) + self.relative_to_lbl = QLabel("Current position:") + self.relative_to = QEnumComboBox(self, RelativeTo) + + self.form_layout = QFormLayout(self) + self.form_layout.setContentsMargins(12, 0, 12, 12) + self.form_layout.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow + ) + + self.form_layout.addRow("", SeparatorWidget()) + # NB relative_to added in self.setMode + self.form_layout.addRow(self.overlap_lbl, self.overlap) + self.form_layout.addRow(self.order_lbl, self.order) + + self.overlap.valueChanged.connect(self.valueChanged) + self.order.currentIndexChanged.connect(self.valueChanged) + self.relative_to.currentIndexChanged.connect(self.valueChanged) + + def setMode(self, mode: Mode) -> None: + if mode == Mode.BOUNDS: + self.relative_to.hide() + self.relative_to_lbl.hide() + self.form_layout.removeWidget(self.relative_to_lbl) + self.form_layout.removeWidget(self.relative_to) + else: + self.relative_to.show() + self.relative_to_lbl.show() + self.form_layout.addRow(self.relative_to_lbl, self.relative_to) From b7fa81965957d59160d782a6714062c90780f216 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 23 Jul 2024 09:32:41 -0400 Subject: [PATCH 13/38] refactor: more grid plan cleanup (#354) * refactor: more grid plan cleanup * don't use setRowVisible --- examples/grid_plan_widget.py | 1 + src/pymmcore_widgets/mda/_core_grid.py | 22 +- src/pymmcore_widgets/useq_widgets/_grid.py | 336 ++++++++++-------- .../useq_widgets/_mda_sequence.py | 2 +- 4 files changed, 194 insertions(+), 167 deletions(-) diff --git a/examples/grid_plan_widget.py b/examples/grid_plan_widget.py index cfe1d0f5d..eef3cdc44 100644 --- a/examples/grid_plan_widget.py +++ b/examples/grid_plan_widget.py @@ -15,6 +15,7 @@ mmc.loadSystemConfiguration() grid_wdg = GridPlanWidget() +grid_wdg.valueChanged.connect(print) grid_wdg.show() app.exec_() diff --git a/src/pymmcore_widgets/mda/_core_grid.py b/src/pymmcore_widgets/mda/_core_grid.py index a30c0cf71..f9088dfdb 100644 --- a/src/pymmcore_widgets/mda/_core_grid.py +++ b/src/pymmcore_widgets/mda/_core_grid.py @@ -1,16 +1,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from pymmcore_plus import CMMCorePlus +from qtpy.QtWidgets import QHBoxLayout, QWidget from pymmcore_widgets.useq_widgets._grid import GridPlanWidget from ._xy_bounds import CoreXYBoundsControl -if TYPE_CHECKING: - from qtpy.QtWidgets import QWidget - class CoreConnectedGridPlanWidget(GridPlanWidget): """[GridPlanWidget](../GridPlanWidget#) connected to a Micro-Manager core instance. @@ -41,9 +37,19 @@ def __init__( self.bottom = self._core_xy_bounds.bottom_edit # replace the lrtb_wdg from the parent widget with the core_xy_bounds widget - self._bounds_wdg.bounds_layout.removeWidget(self._bounds_wdg.lrtb_wdg) - self._bounds_wdg.bounds_layout.insertRow(0, self._core_xy_bounds) - self._bounds_wdg.lrtb_wdg.hide() + # self.bounds_wdg.bounds_layout.removeWidget(self.bounds_wdg.lrtb_wdg) + # self.bounds_wdg.lrtb_wdg.hide() + + for wdg in self.bounds_wdg.children(): + if isinstance(wdg, QWidget): + wdg.setParent(self) + wdg.hide() + QWidget().setLayout(self.bounds_wdg.layout()) + + new_layout = QHBoxLayout() + new_layout.addWidget(self._core_xy_bounds) + self.bounds_wdg.setLayout(new_layout) + # self.bounds_wdg.layout().addWidget(self._core_xy_bounds) # connect self.top.valueChanged.connect(self._on_change) diff --git a/src/pymmcore_widgets/useq_widgets/_grid.py b/src/pymmcore_widgets/useq_widgets/_grid.py index 8282b4e08..0c9f99c0b 100644 --- a/src/pymmcore_widgets/useq_widgets/_grid.py +++ b/src/pymmcore_widgets/useq_widgets/_grid.py @@ -1,10 +1,10 @@ from __future__ import annotations from enum import Enum -from typing import Literal +from typing import TYPE_CHECKING import useq -from qtpy.QtCore import QSize, Qt, Signal +from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( QAbstractButton, QButtonGroup, @@ -25,6 +25,13 @@ from pymmcore_widgets._util import SeparatorWidget +if TYPE_CHECKING: + from typing import Literal, TypeAlias + + GridPlan: TypeAlias = ( + useq.GridFromEdges | useq.GridRowsColumns | useq.GridWidthHeight + ) + class RelativeTo(Enum): center = "center" @@ -49,6 +56,26 @@ class Mode(Enum): def __str__(self) -> str: return self.value + def to_useq_cls(self) -> type[GridPlan]: + return _MODE_TO_USEQ[self] + + @classmethod + def for_grid_plan(cls, plan: GridPlan) -> Mode: + if isinstance(plan, useq.GridRowsColumns): + return cls.NUMBER + elif isinstance(plan, useq.GridFromEdges): + return cls.BOUNDS + elif isinstance(plan, useq.GridWidthHeight): + return cls.AREA + raise TypeError(f"Unknown grid plan type: {type(plan)}") # pragma: no cover + + +_MODE_TO_USEQ: dict[Mode, type[GridPlan]] = { + Mode.NUMBER: useq.GridRowsColumns, + Mode.BOUNDS: useq.GridFromEdges, + Mode.AREA: useq.GridWidthHeight, +} + class GridPlanWidget(QScrollArea): """Widget to edit a [`useq-schema` GridPlan](https://pymmcore-plus.github.io/useq-schema/schema/axes/#grid-plans).""" @@ -61,42 +88,60 @@ def __init__(self, parent: QWidget | None = None): self._fov_width: float | None = None self._fov_height: float | None = None - self._mode_number_radio = QRadioButton() - self._mode_number_radio.setText("Fields of View") - self._mode_area_radio = QRadioButton() - self._mode_area_radio.setText("Width && Height") - self._mode_bounds_radio = QRadioButton() - self._mode_bounds_radio.setText("Absolute Bounds") + # WIDGETS ----------------------------------------------- + + # Radio buttons to select the mode + self._mode_number_radio = QRadioButton("Fields of View") + self._mode_area_radio = QRadioButton("Width && Height") + self._mode_bounds_radio = QRadioButton("Absolute Bounds") + # group the radio buttons together self._mode_btn_group = QButtonGroup() self._mode_btn_group.addButton(self._mode_number_radio) self._mode_btn_group.addButton(self._mode_area_radio) self._mode_btn_group.addButton(self._mode_bounds_radio) self._mode_btn_group.buttonToggled.connect(self.setMode) - top_layout = QHBoxLayout() - top_layout.addWidget(QLabel("Create Grid Using:")) - top_layout.addWidget(self._mode_number_radio) - top_layout.addWidget(self._mode_area_radio) - top_layout.addWidget(self._mode_bounds_radio) - - self.stack = _ResizableStackedWidget(self) - self._row_col_wdg = _RowsColsWidget() - self.stack.addWidget(self._row_col_wdg) - self._width_height_wdg = _WidthHeightWidget() - self.stack.addWidget(self._width_height_wdg) - self._bounds_wdg = _BoundsWidget() - self.stack.addWidget(self._bounds_wdg) + self.row_col_wdg = _RowsColsWidget() + self.width_height_wdg = _WidthHeightWidget() + self.bounds_wdg = _BoundsWidget() + # ease of lookup + self._mode_to_widget: dict[ + Mode, _RowsColsWidget | _WidthHeightWidget | _BoundsWidget + ] = { + Mode.NUMBER: self.row_col_wdg, + Mode.AREA: self.width_height_wdg, + Mode.BOUNDS: self.bounds_wdg, + } self._bottom_stuff = _BottomStuff() + # aliases + self.overlap = self._bottom_stuff.overlap + self.order = self._bottom_stuff.order + self.relative_to = self._bottom_stuff.relative_to + + # LAYOUT ----------------------------------------------- + + # radio buttons on the top row + btns_row = QHBoxLayout() + btns_row.addWidget(QLabel("Create Grid Using:")) + btns_row.addWidget(self._mode_number_radio) + btns_row.addWidget(self._mode_area_radio) + btns_row.addWidget(self._mode_bounds_radio) + + # stack the different mode widgets on top of each other + self._stack = _ResizableStackedWidget(self) + self._stack.addWidget(self.row_col_wdg) + self._stack.addWidget(self.width_height_wdg) + self._stack.addWidget(self.bounds_wdg) # wrap the whole thing in an inner widget so we can put it in this ScrollArea inner_widget = QWidget(self) - layout = QVBoxLayout(inner_widget) - layout.addLayout(top_layout) - layout.addWidget(SeparatorWidget()) - layout.addWidget(self.stack) - layout.addWidget(self._bottom_stuff) - layout.addStretch() + main_layout = QVBoxLayout(inner_widget) + main_layout.addLayout(btns_row) + main_layout.addWidget(SeparatorWidget()) + main_layout.addWidget(self._stack) + main_layout.addWidget(self._bottom_stuff) + main_layout.addStretch(1) self.setWidget(inner_widget) self.setWidgetResizable(True) @@ -111,6 +156,13 @@ def __init__(self, parent: QWidget | None = None): # likely future Qt versions will fix this. inner_widget.setStyleSheet("QRadioButton {color: none}") + # CONNECTIONS ------------------------------------------ + + self.row_col_wdg.valueChanged.connect(self._on_change) + self.width_height_wdg.valueChanged.connect(self._on_change) + self.bounds_wdg.valueChanged.connect(self._on_change) + self._bottom_stuff.valueChanged.connect(self._on_change) + # ------------------------- Public API ------------------------- def mode(self) -> Mode: @@ -125,26 +177,24 @@ def setMode(self, mode: Mode | Literal["number", "area", "bounds"]) -> None: mode : Mode | Literal["number", "area", "bounds"] The mode to set. """ - btn_map: dict[QAbstractButton, Mode] = { - self._mode_number_radio: Mode.NUMBER, - self._mode_area_radio: Mode.AREA, - self._mode_bounds_radio: Mode.BOUNDS, - } - if isinstance(mode, str): - mode = Mode(mode) - elif isinstance(mode, QRadioButton): + if isinstance(mode, QRadioButton): + btn_map: dict[QAbstractButton, Mode] = { + self._mode_number_radio: Mode.NUMBER, + self._mode_area_radio: Mode.AREA, + self._mode_bounds_radio: Mode.BOUNDS, + } mode = btn_map[mode] + elif isinstance(mode, str): + mode = Mode(mode) previous, self._mode = getattr(self, "_mode", None), mode if previous != self._mode: - for i, m in enumerate(Mode): - if mode == m: - self.stack.setCurrentIndex(i) + current_wdg = self._mode_to_widget[self._mode] + self._stack.setCurrentWidget(current_wdg) + self._bottom_stuff.setMode(mode) self._on_change() - self._bottom_stuff.setMode(mode) - - def value(self) -> useq.GridFromEdges | useq.GridRowsColumns | useq.GridWidthHeight: + def value(self) -> GridPlan: """Return the current value of the widget as a [`useq-schema` GridPlan](https://pymmcore-plus.github.io/useq-schema/schema/axes/#grid-plans). Returns @@ -153,41 +203,17 @@ def value(self) -> useq.GridFromEdges | useq.GridRowsColumns | useq.GridWidthHei The current [GridPlan](https://pymmcore-plus.github.io/useq-schema/schema/axes/#grid-plans) value of the widget. """ - over = self._bottom_stuff.overlap.value() - order = self._bottom_stuff.order.currentEnum() - common = { - "overlap": (over, over), - "mode": order.value, + kwargs = { + **self._stack.currentWidget().value(), + **self._bottom_stuff.value(), "fov_width": self._fov_width, "fov_height": self._fov_height, } + if self._mode not in {Mode.NUMBER, Mode.AREA}: + kwargs.pop("relative_to", None) + return self._mode.to_useq_cls()(**kwargs) - if self._mode == Mode.NUMBER: - return useq.GridRowsColumns( - rows=self._row_col_wdg.rows.value(), - columns=self._row_col_wdg.columns.value(), - relative_to=self._bottom_stuff.relative_to.currentEnum().value, - **common, - ) - elif self._mode == Mode.BOUNDS: - return useq.GridFromEdges( - top=self._bounds_wdg.top.value(), - left=self._bounds_wdg.left.value(), - bottom=self._bounds_wdg.bottom.value(), - right=self._bounds_wdg.right.value(), - **common, - ) - elif self._mode == Mode.AREA: - # converting width and height to microns because GridWidthHeight expects µm - return useq.GridWidthHeight( - width=self._width_height_wdg.area_width.value() * 1000, - height=self._width_height_wdg.area_height.value() * 1000, - relative_to=self._bottom_stuff.relative_to.currentEnum().value, - **common, - ) - raise NotImplementedError - - def setValue(self, value: useq.GridFromEdges | useq.GridRowsColumns) -> None: + def setValue(self, value: GridPlan) -> None: """Set the current value of the widget from a [`useq-schema` GridPlan](https://pymmcore-plus.github.io/useq-schema/schema/axes/#grid-plans). Parameters @@ -196,41 +222,19 @@ def setValue(self, value: useq.GridFromEdges | useq.GridRowsColumns) -> None: The [`useq-schema` GridPlan](https://pymmcore-plus.github.io/useq-schema/schema/axes/#grid-plans) to set. """ - with signals_blocked(self): - if isinstance(value, useq.GridRowsColumns): - self._row_col_wdg.rows.setValue(value.rows) - self._row_col_wdg.columns.setValue(value.columns) - self._bottom_stuff.relative_to.setCurrentText(value.relative_to.value) - elif isinstance(value, useq.GridFromEdges): - self._bounds_wdg.top.setValue(value.top) - self._bounds_wdg.left.setValue(value.left) - self._bounds_wdg.bottom.setValue(value.bottom) - self._bounds_wdg.right.setValue(value.right) - elif isinstance(value, useq.GridWidthHeight): - # GridWidthHeight width and height are expressed in µm but this widget - # uses mm, so we convert width and height to mm here - self._width_height_wdg.area_width.setValue(value.width / 1000) - self._width_height_wdg.area_height.setValue(value.height / 1000) - self._bottom_stuff.relative_to.setCurrentText(value.relative_to.value) - else: # pragma: no cover - raise TypeError(f"Expected useq grid plan, got {type(value)}") + mode = Mode.for_grid_plan(value) + with signals_blocked(self): + mode_wdg = self._mode_to_widget[mode] + mode_wdg.setValue(value) # type: ignore [arg-type] + self._stack.setCurrentWidget(mode_wdg) if value.fov_height: self._fov_height = value.fov_height if value.fov_width: self._fov_width = value.fov_width - - if value.overlap: - self._bottom_stuff.overlap.setValue(value.overlap[0]) - - self._bottom_stuff.order.setCurrentEnum(OrderMode(value.mode.value)) - - mode = { - useq.GridRowsColumns: Mode.NUMBER, - useq.GridFromEdges: Mode.BOUNDS, - useq.GridWidthHeight: Mode.AREA, - }[type(value)] - self.setMode(mode) + with signals_blocked(self._bottom_stuff): + self._bottom_stuff.setValue(value) + self.setMode(mode) self._on_change() @@ -254,12 +258,6 @@ def fovHeight(self) -> float | None: # ------------------------- Private API ------------------------- - def sizeHint(self) -> QSize: - """Return the size hint for the viewport.""" - sz = super().sizeHint() - sz.setHeight(200) # encourage vertical scrolling - return sz - def _on_change(self) -> None: if (val := self.value()) is None: return # pragma: no cover @@ -282,6 +280,7 @@ def __init__(self) -> None: self.columns.setSuffix(" fields") layout = QFormLayout(self) + layout.setContentsMargins(12, 12, 12, 4) layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) layout.addRow("Grid Rows:", self.rows) layout.addRow("Grid Cols:", self.columns) @@ -289,6 +288,13 @@ def __init__(self) -> None: self.rows.valueChanged.connect(self.valueChanged) self.columns.valueChanged.connect(self.valueChanged) + def value(self) -> dict[str, int]: + return {"rows": self.rows.value(), "columns": self.columns.value()} + + def setValue(self, plan: useq.GridRowsColumns) -> None: + self.rows.setValue(plan.rows) + self.columns.setValue(plan.columns) + class _WidthHeightWidget(QWidget): valueChanged = Signal() @@ -311,6 +317,7 @@ def __init__(self) -> None: self.area_height.setSingleStep(0.1) layout = QFormLayout(self) + layout.setContentsMargins(12, 12, 12, 4) layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) layout.addRow("Width:", self.area_width) layout.addRow("Height:", self.area_height) @@ -318,6 +325,19 @@ def __init__(self) -> None: self.area_width.valueChanged.connect(self.valueChanged) self.area_height.valueChanged.connect(self.valueChanged) + def value(self) -> dict[str, float]: + # converting width and height to microns because GridWidthHeight expects µm + return { + "width": self.area_width.value() * 1000, + "height": self.area_height.value() * 1000, + } + + def setValue(self, plan: useq.GridWidthHeight) -> None: + # GridWidthHeight width and height are expressed in µm but this widget + # uses mm, so we convert width and height to mm here + self.area_width.setValue(plan.width / 1000) + self.area_height.setValue(plan.height / 1000) + class _BoundsWidget(QWidget): valueChanged = Signal() @@ -327,41 +347,44 @@ def __init__(self) -> None: self.left = QDoubleSpinBox() self.left.setRange(-10000, 10000) - self.left.setValue(0) self.left.setDecimals(3) - self.left.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons) self.top = QDoubleSpinBox() self.top.setRange(-10000, 10000) - self.top.setValue(0) self.top.setDecimals(3) - self.top.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons) self.right = QDoubleSpinBox() self.right.setRange(-10000, 10000) - self.right.setValue(0) self.right.setDecimals(3) - self.right.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons) self.bottom = QDoubleSpinBox() self.bottom.setRange(-10000, 10000) - self.bottom.setValue(0) self.bottom.setDecimals(3) - self.bottom.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons) - self.lrtb_wdg = QWidget() - lrtb_layout = QFormLayout(self.lrtb_wdg) - lrtb_layout.setContentsMargins(12, 0, 12, 12) - lrtb_layout.addRow("Left:", self.left) - lrtb_layout.addRow("Top:", self.top) - lrtb_layout.addRow("Right:", self.right) - lrtb_layout.addRow("Bottom:", self.bottom) - - self.bounds_layout = QFormLayout(self) - self.bounds_layout.addWidget(self.lrtb_wdg) + form = QFormLayout(self) + form.setContentsMargins(12, 12, 12, 4) + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + form.addRow("Left:", self.left) + form.addRow("Top:", self.top) + form.addRow("Right:", self.right) + form.addRow("Bottom:", self.bottom) self.top.valueChanged.connect(self.valueChanged) self.bottom.valueChanged.connect(self.valueChanged) self.left.valueChanged.connect(self.valueChanged) self.right.valueChanged.connect(self.valueChanged) + def value(self) -> dict[str, float]: + return { + "left": self.left.value(), + "top": self.top.value(), + "right": self.right.value(), + "bottom": self.bottom.value(), + } + + def setValue(self, plan: useq.GridFromEdges) -> None: + self.left.setValue(plan.left) + self.top.setValue(plan.top) + self.right.setValue(plan.right) + self.bottom.setValue(plan.bottom) + class _ResizableStackedWidget(QStackedWidget): def __init__(self, parent: QWidget | None = None) -> None: @@ -371,22 +394,14 @@ def __init__(self, parent: QWidget | None = None) -> None: def addWidget(self, wdg: QWidget | None) -> int: if wdg is not None: wdg.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) - return super().addWidget(wdg) # type: ignore + return super().addWidget(wdg) # type: ignore [no-any-return] def onCurrentChanged(self, idx: int) -> None: for i in range(self.count()): - wdg = self.widget(i) - if wdg is None: - continue - if i == idx: - wdg.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding - ) - else: - wdg.setSizePolicy( - QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored - ) - wdg.adjustSize() + plc = QSizePolicy.Policy.Minimum if i == idx else QSizePolicy.Policy.Ignored + if wdg := self.widget(i): + wdg.setSizePolicy(plc, plc) + wdg.adjustSize() self.adjustSize() @@ -396,38 +411,43 @@ class _BottomStuff(QWidget): def __init__(self) -> None: super().__init__() - self.overlap_lbl = QLabel("Overlap:") self.overlap = QDoubleSpinBox() self.overlap.setRange(-1000, 1000) self.overlap.setValue(0) self.overlap.setSuffix(" %") - self.order_lbl = QLabel("Acquisition order:") self.order = QEnumComboBox(self, OrderMode) - self.relative_to_lbl = QLabel("Current position:") self.relative_to = QEnumComboBox(self, RelativeTo) - self.form_layout = QFormLayout(self) - self.form_layout.setContentsMargins(12, 0, 12, 12) - self.form_layout.setFieldGrowthPolicy( + self._form_layout = QFormLayout(self) + self._form_layout.setContentsMargins(12, 0, 12, 12) + self._form_layout.setFieldGrowthPolicy( QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow ) - self.form_layout.addRow("", SeparatorWidget()) - # NB relative_to added in self.setMode - self.form_layout.addRow(self.overlap_lbl, self.overlap) - self.form_layout.addRow(self.order_lbl, self.order) + self._form_layout.addRow("", SeparatorWidget()) + self._form_layout.addRow("Overlap:", self.overlap) + self._form_layout.addRow("Acquisition order:", self.order) + self._form_layout.addRow("Current position:", self.relative_to) self.overlap.valueChanged.connect(self.valueChanged) self.order.currentIndexChanged.connect(self.valueChanged) self.relative_to.currentIndexChanged.connect(self.valueChanged) def setMode(self, mode: Mode) -> None: - if mode == Mode.BOUNDS: - self.relative_to.hide() - self.relative_to_lbl.hide() - self.form_layout.removeWidget(self.relative_to_lbl) - self.form_layout.removeWidget(self.relative_to) - else: - self.relative_to.show() - self.relative_to_lbl.show() - self.form_layout.addRow(self.relative_to_lbl, self.relative_to) + vis = mode != Mode.BOUNDS + for role in (QFormLayout.ItemRole.LabelRole, QFormLayout.ItemRole.FieldRole): + self._form_layout.itemAt(3, role).widget().setVisible(vis) + + def value(self) -> dict: + return { + "overlap": (self.overlap.value(), self.overlap.value()), + "mode": self.order.currentEnum().value, + "relative_to": self.relative_to.currentEnum().value, + } + + def setValue(self, plan: GridPlan) -> None: + if plan.overlap: + self.overlap.setValue(plan.overlap[0]) + if hasattr(plan, "relative_to"): + self.relative_to.setCurrentText(plan.relative_to.value) + self.order.setCurrentEnum(OrderMode(plan.mode.value)) diff --git a/src/pymmcore_widgets/useq_widgets/_mda_sequence.py b/src/pymmcore_widgets/useq_widgets/_mda_sequence.py index 0f147493b..6a9378e8d 100644 --- a/src/pymmcore_widgets/useq_widgets/_mda_sequence.py +++ b/src/pymmcore_widgets/useq_widgets/_mda_sequence.py @@ -329,7 +329,7 @@ def __init__( layout = QVBoxLayout(self) layout.addLayout(top_row) - layout.addWidget(self.tab_wdg) + layout.addWidget(self.tab_wdg, 1) layout.addLayout(cbox_row) layout.addLayout(bot_row) From 920ec4aed705b9975b5a994734e270aee63a4dae Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 23 Jul 2024 10:47:59 -0400 Subject: [PATCH 14/38] refactor: refactor stage widget (#334) * fix: fix stage widget styles and refactor * minor * revert position labels layout * connect spinboxes * simplify * fix test * fix * 1 decimal --- examples/stage_widget.py | 31 +- src/pymmcore_widgets/_stage_widget.py | 588 +++++++++++++------------- tests/test_stage_widget.py | 66 ++- 3 files changed, 341 insertions(+), 344 deletions(-) diff --git a/examples/stage_widget.py b/examples/stage_widget.py index 8058e3d47..2fe7ce675 100644 --- a/examples/stage_widget.py +++ b/examples/stage_widget.py @@ -15,23 +15,18 @@ mmc.loadSystemConfiguration() wdg = QWidget() -wdg.setLayout(QHBoxLayout()) - -stage_dev_list = list(mmc.getLoadedDevicesOfType(DeviceType.XYStage)) -stage_dev_list.extend(iter(mmc.getLoadedDevicesOfType(DeviceType.Stage))) - -for stage_dev in stage_dev_list: - if mmc.getDeviceType(stage_dev) is DeviceType.XYStage: - bx = QGroupBox("XY Control") - bx.setLayout(QHBoxLayout()) - bx.layout().addWidget(StageWidget(device=stage_dev)) - wdg.layout().addWidget(bx) - if mmc.getDeviceType(stage_dev) is DeviceType.Stage: - bx = QGroupBox("Z Control") - bx.setLayout(QHBoxLayout()) - bx.layout().addWidget(StageWidget(device=stage_dev)) - wdg.layout().addWidget(bx) +wdg_layout = QHBoxLayout(wdg) + +stages = list(mmc.getLoadedDevicesOfType(DeviceType.XYStage)) +stages.extend(mmc.getLoadedDevicesOfType(DeviceType.Stage)) +for stage in stages: + lbl = "Z" if mmc.getDeviceType(stage) == DeviceType.Stage else "XY" + bx = QGroupBox(f"{lbl} Control") + bx_layout = QHBoxLayout(bx) + bx_layout.setContentsMargins(0, 0, 0, 0) + bx_layout.addWidget(StageWidget(device=stage, position_label_below=True)) + wdg_layout.addWidget(bx) -wdg.show() -app.exec_() +wdg.show() +app.exec() diff --git a/src/pymmcore_widgets/_stage_widget.py b/src/pymmcore_widgets/_stage_widget.py index 7f5da84ee..e3b740a1b 100644 --- a/src/pymmcore_widgets/_stage_widget.py +++ b/src/pymmcore_widgets/_stage_widget.py @@ -1,16 +1,15 @@ from __future__ import annotations -from itertools import chain, product, repeat -from typing import ClassVar +from itertools import product +from typing import cast from fonticon_mdi6 import MDI6 -from pymmcore_plus import CMMCorePlus, DeviceType -from qtpy.QtCore import Qt, QTimer +from pymmcore_plus import CMMCorePlus, DeviceType, Keyword +from qtpy.QtCore import Qt, QTimerEvent, Signal from qtpy.QtWidgets import ( QCheckBox, QDoubleSpinBox, QGridLayout, - QHBoxLayout, QLabel, QPushButton, QRadioButton, @@ -21,37 +20,166 @@ from superqt.fonticon import setTextIcon from superqt.utils import signals_blocked -AlignCenter = Qt.AlignmentFlag.AlignCenter -PREFIX = MDI6.__name__.lower() -STAGE_DEVICES = {DeviceType.Stage, DeviceType.XYStage} -STYLE = """ -QPushButton { - border: none; - background: transparent; - color: rgb(0, 180, 0); - font-size: 40px; +CORE = Keyword.CoreDevice +XY_STAGE = Keyword.CoreXYStage +FOCUS = Keyword.CoreFocus + +MOVE_BUTTONS: dict[str, tuple[int, int, int, int]] = { + # btn glyph (r, c, xmag, ymag) + MDI6.chevron_triple_up: (0, 3, 0, 3), + MDI6.chevron_double_up: (1, 3, 0, 2), + MDI6.chevron_up: (2, 3, 0, 1), + MDI6.chevron_down: (4, 3, 0, -1), + MDI6.chevron_double_down: (5, 3, 0, -2), + MDI6.chevron_triple_down: (6, 3, 0, -3), + MDI6.chevron_triple_left: (3, 0, -3, 0), + MDI6.chevron_double_left: (3, 1, -2, 0), + MDI6.chevron_left: (3, 2, -1, 0), + MDI6.chevron_right: (3, 4, 1, 0), + MDI6.chevron_double_right: (3, 5, 2, 0), + MDI6.chevron_triple_right: (3, 6, 3, 0), } -QPushButton:hover:!pressed { - color: rgb(0, 255, 0); -} -QPushButton:pressed { - color: rgb(0, 100, 0); -} -QSpinBox { - min-width: 35px; - height: 22px; -} -QLabel { - color: #999; -} -QCheckBox { - color: #999; -} -QCheckBox::indicator { - width: 11px; - height: 11px; -} -""" + + +class MoveStageButton(QPushButton): + def __init__(self, glyph: str, xmag: int, ymag: int, parent: QWidget | None = None): + super().__init__(parent=parent) + self.xmag = xmag + self.ymag = ymag + self.setAutoRepeat(True) + self.setFlat(True) + self.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.setCursor(Qt.CursorShape.PointingHandCursor) + setTextIcon(self, glyph) + self.setStyleSheet( + """ + MoveStageButton { + border: none; + background: transparent; + color: rgb(0, 180, 0); + font-size: 36px; + } + MoveStageButton:hover:!pressed { + color: rgb(0, 255, 0); + } + MoveStageButton:pressed { + color: rgb(0, 100, 0); + } + """ + ) + + +class HaltButton(QPushButton): + def __init__(self, core: CMMCorePlus, parent: QWidget | None = None): + super().__init__("STOP!", parent=parent) + self._core = core + self.setStyleSheet("color: red; font-weight: bold;") + self.clicked.connect(self._on_clicked) + + def _on_clicked(self) -> None: + for stage in self._core.getLoadedDevicesOfType(DeviceType.Stage): + self._core.stop(stage) + for stage in self._core.getLoadedDevicesOfType(DeviceType.XYStage): + self._core.stop(stage) + + +class StageMovementButtons(QWidget): + """Grid of buttons to move a stage in 2D. + + ^ + << < [dstep] > >> + v + """ + + moveRequested = Signal(float, float) + + def __init__( + self, levels: int = 2, show_x: bool = True, parent: QWidget | None = None + ) -> None: + super().__init__(parent) + self._levels = levels + self._x_visible = show_x + + btn_grid = QGridLayout(self) + btn_grid.setContentsMargins(0, 0, 0, 0) + btn_grid.setSpacing(0) + for glyph, (row, col, xmag, ymag) in MOVE_BUTTONS.items(): + btn = MoveStageButton(glyph, xmag, ymag) + btn.clicked.connect(self._on_move_btn_clicked) + btn_grid.addWidget(btn, row, col, Qt.AlignmentFlag.AlignCenter) + + # step size spinbox in the middle of the move buttons + self.step_size = QDoubleSpinBox() + self.step_size.setSuffix(" µm") + self.step_size.setDecimals(1) + self.step_size.setToolTip("Set step size in µm") + self.step_size.setValue(10) + self.step_size.setMaximum(99999) + self.step_size.setAttribute(Qt.WidgetAttribute.WA_MacShowFocusRect, 0) + self.step_size.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons) + self.step_size.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.step_size.valueChanged.connect(self._update_tooltips) + + btn_grid.addWidget(self.step_size, 3, 3, Qt.AlignmentFlag.AlignCenter) + + self.set_visible_levels(self._levels) + self.set_x_visible(self._x_visible) + self._update_tooltips() + + def _on_move_btn_clicked(self) -> None: + btn = cast("MoveStageButton", self.sender()) + self.moveRequested.emit(self._scale(btn.xmag), self._scale(btn.ymag)) + + def set_visible_levels(self, levels: int) -> None: + """Hide upper-level stage buttons as desired. Levels must be between 1-3.""" + if not (1 <= levels <= 3): + raise ValueError("levels must be between 1-3") + self._levels = levels + + btn_layout = cast("QGridLayout", self.layout()) + for btn in self.findChildren(MoveStageButton): + btn.show() + + to_hide: set[tuple[int, int]] = set() + if levels < 3: + to_hide.update(product(range(7), (0, 6))) + if levels < 2: + to_hide.update(product(range(1, 6), (1, 5))) + # add all the flipped indices as well + to_hide.update((c, r) for r, c in list(to_hide)) + + for r, c in to_hide: + if (item := btn_layout.itemAtPosition(r, c)) and (wdg := item.widget()): + wdg.hide() + + def set_x_visible(self, visible: bool) -> None: + """Show or hide the horizontal buttons.""" + self._x_visible = visible + btn_layout = cast("QGridLayout", self.layout()) + cols: list[int] = [2, 4] + if self._levels > 1: + cols += [1, 5] + if self._levels > 2: + cols += [0, 6] + + for c in cols: + if (item := btn_layout.itemAtPosition(3, c)) and (wdg := item.widget()): + wdg.setVisible(visible) + + def _update_tooltips(self) -> None: + """Update tooltips for the move buttons.""" + for btn in self.findChildren(MoveStageButton): + if xmag := btn.xmag: + btn.setToolTip(f"move by {self._scale(xmag)} µm") + elif ymag := btn.ymag: + btn.setToolTip(f"move by {self._scale(ymag)} µm") + + def _scale(self, mag: int) -> float: + """Convert step mag of (1, 2, 3) to absolute XY units. + + Can be used to step 1x field of view, etc... + """ + return float(mag * self.step_size.value()) class StageWidget(QWidget): @@ -63,8 +191,9 @@ class StageWidget(QWidget): Stage device. levels: int | None: Number of "arrow" buttons per widget per direction, by default, 2. - step: float | None: - Starting step size to use for the spinbox in the middle, by default, 10. + position_label_below: bool | None + If True, the position labels will appear below the move buttons. + If False, the position labels will appear to the right of the move buttons. parent : QWidget | None Optional parent widget. mmcore : CMMCorePlus | None @@ -74,317 +203,196 @@ class StageWidget(QWidget): [`CMMCorePlus.instance`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.instance]. """ - # fmt: off - BTNS: ClassVar[ dict]= { - # btn glyph (r, c, xmag, ymag) - MDI6.chevron_triple_up: (0, 3, 0, 3), - MDI6.chevron_double_up: (1, 3, 0, 2), - MDI6.chevron_up: (2, 3, 0, 1), - MDI6.chevron_down: (4, 3, 0, -1), - MDI6.chevron_double_down: (5, 3, 0, -2), - MDI6.chevron_triple_down: (6, 3, 0, -3), - MDI6.chevron_triple_left: (3, 0, -3, 0), - MDI6.chevron_double_left: (3, 1, -2, 0), - MDI6.chevron_left: (3, 2, -1, 0), - MDI6.chevron_right: (3, 4, 1, 0), - MDI6.chevron_double_right: (3, 5, 2, 0), - MDI6.chevron_triple_right: (3, 6, 3, 0), - } BTN_SIZE = 30 - # fmt: on def __init__( self, device: str, - levels: int | None = 2, + levels: int = 2, *, - step: float = 10, + position_label_below: bool = True, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None, ): super().__init__(parent=parent) - self.setStyleSheet(STYLE) - self._mmc = mmcore or CMMCorePlus.instance() self._levels = levels self._device = device + self._poll_timer_id: int | None = None + self._dtype = self._mmc.getDeviceType(self._device) - assert self._dtype in STAGE_DEVICES, f"{self._dtype} not in {STAGE_DEVICES}" + if self._dtype not in {DeviceType.Stage, DeviceType.XYStage}: + raise ValueError("This widget only supports Stage and XYStage devices.") - self._create_widget(step) - self._connect_events() - self._set_as_default() + self._is_2axis = self._dtype is DeviceType.XYStage + self._Ylabel = "Y" if self._is_2axis else self._device - self.destroyed.connect(self._disconnect) + # WIDGETS ------------------------------------------------ - def step(self) -> float: - """Return the current step size.""" - return self._step.value() # type: ignore - - def setStep(self, step: float) -> None: - """Set the step size.""" - self._step.setValue(step) + self._move_btns = StageMovementButtons(self._levels, self._is_2axis) + self._step = self._move_btns.step_size - def _create_widget(self, step: float) -> None: - self._step = QDoubleSpinBox() - self._step.setValue(step) - self._step.setMaximum(99999) - self._step.valueChanged.connect(self._update_ttips) - self._step.clearFocus() - self._step.setAttribute(Qt.WidgetAttribute.WA_MacShowFocusRect, 0) - self._step.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons) - self._step.setAlignment(AlignCenter) - - self._btns = QWidget() - self._btns.setLayout(QGridLayout()) - self._btns.layout().setContentsMargins(0, 0, 0, 0) - self._btns.layout().setSpacing(0) - for glyph, (row, col, *_) in self.BTNS.items(): - btn = QPushButton() - btn.setAutoRepeat(True) - btn.setFlat(True) - btn.setFixedSize(self.BTN_SIZE, self.BTN_SIZE) - btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) - btn.setCursor(Qt.CursorShape.PointingHandCursor) - setTextIcon(btn, glyph) - btn.clicked.connect(self._on_click) - self._btns.layout().addWidget(btn, row, col, AlignCenter) - - self._btns.layout().addWidget(self._step, 3, 3, AlignCenter) - self._set_visible_levels(self._levels) # type: ignore - self._set_xy_visible() - self._update_ttips() - - self._readout = QLabel() - self._readout.setAlignment(AlignCenter) - self._update_position_label() - - self._poll_cb = QCheckBox("poll") - self._poll_cb.setMaximumWidth(50) - self._poll_timer = QTimer() - self._poll_timer.setInterval(500) - self._poll_timer.timeout.connect(self._update_position_label) - self._poll_cb.toggled.connect(self._toggle_poll_timer) + self._pos_label = QLabel() + self._pos_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._poll_cb = QCheckBox("Poll") self.snap_checkbox = QCheckBox(text="Snap on Click") - self._invert_x = QCheckBox(text="Invert X") - self._invert_y = QCheckBox(text="Invert Y") - - self.radiobutton = QRadioButton(text="Set as Default") - self.radiobutton.toggled.connect(self._on_radiobutton_toggled) - - top_row = QWidget() - top_row_layout = QHBoxLayout() - top_row_layout.setAlignment(AlignCenter) - top_row.setLayout(top_row_layout) - top_row.layout().addWidget(self.radiobutton) - - bottom_row_1 = QWidget() - bottom_row_1.setLayout(QHBoxLayout()) - bottom_row_1.layout().addWidget(self._readout) - - bottom_row_2 = QWidget() - bottom_row_2_layout = QGridLayout() - bottom_row_2_layout.setSpacing(15) - bottom_row_2_layout.setContentsMargins(0, 0, 0, 0) - bottom_row_2_layout.setAlignment(AlignCenter) - bottom_row_2.setLayout(bottom_row_2_layout) - bottom_row_2.layout().addWidget(self.snap_checkbox, 0, 0) - bottom_row_2.layout().addWidget(self._poll_cb, 0, 1) - bottom_row_2.layout().addWidget(self._invert_x, 1, 0) - bottom_row_2.layout().addWidget(self._invert_y, 1, 1) - - self.setLayout(QVBoxLayout()) - self.layout().setSpacing(0) - self.layout().setContentsMargins(5, 5, 5, 5) - self.layout().addWidget(top_row) - self.layout().addWidget(self._btns, AlignCenter) - self.layout().addWidget(bottom_row_1) - self.layout().addWidget(bottom_row_2) - - if self._dtype is not DeviceType.XYStage: + self._invert_y = QCheckBox(text=f"Invert {self._Ylabel}") + self._set_as_default_btn = QRadioButton(text="Set as Default") + # no need to show the "set as default" button if there is only one device + if len(self._mmc.getLoadedDevicesOfType(self._dtype)) < 2: + self._set_as_default_btn.hide() + + # LAYOUT ------------------------------------------------ + + # checkboxes below the move buttons + chxbox_grid = QGridLayout() + chxbox_grid.setSpacing(12) + chxbox_grid.setContentsMargins(0, 0, 0, 0) + chxbox_grid.setAlignment(Qt.AlignmentFlag.AlignCenter) + chxbox_grid.addWidget(self.snap_checkbox, 0, 0) + chxbox_grid.addWidget(self._poll_cb, 0, 1) + chxbox_grid.addWidget(self._invert_x, 1, 0) + chxbox_grid.addWidget(self._invert_y, 1, 1) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.addWidget(self._set_as_default_btn, 0, Qt.AlignmentFlag.AlignCenter) + main_layout.addWidget(self._move_btns, Qt.AlignmentFlag.AlignCenter) + main_layout.addLayout(chxbox_grid) + + # pos label can appear either below or to the right of the move buttons + if position_label_below: + main_layout.insertWidget(2, self._pos_label) + else: + move_btns_layout = cast("QGridLayout", self._move_btns.layout()) + move_btns_layout.addLayout( + self._pos_label, 4, 4, 2, 2, Qt.AlignmentFlag.AlignBottom + ) + + if not self._is_2axis: self._invert_x.hide() - self._invert_y.hide() - def _connect_events(self) -> None: + # SIGNALS ----------------------------------------------- + + self._set_as_default_btn.toggled.connect(self._on_radiobutton_toggled) + self._move_btns.moveRequested.connect(self._on_move_requested) + self._poll_cb.toggled.connect(self._toggle_poll_timer) self._mmc.events.propertyChanged.connect(self._on_prop_changed) self._mmc.events.systemConfigurationLoaded.connect(self._on_system_cfg) - if self._dtype is DeviceType.XYStage: - event = self._mmc.events.XYStagePositionChanged - elif self._dtype is DeviceType.Stage: - event = self._mmc.events.stagePositionChanged - event.connect(self._update_position_label) + if self._is_2axis: + pos_event = self._mmc.events.XYStagePositionChanged + else: + pos_event = self._mmc.events.stagePositionChanged + pos_event.connect(self._update_position_from_core) + self.destroyed.connect(self._disconnect) + + # INITIALIZATION ---------------------------------------- + + self._update_position_from_core() + self._set_as_default() + + def step(self) -> float: + """Return the current step size.""" + return self._step.value() # type: ignore + + def setStep(self, step: float) -> None: + """Set the step size.""" + self._step.setValue(step) def _enable_wdg(self, enabled: bool) -> None: self._step.setEnabled(enabled) - self._btns.setEnabled(enabled) + self._move_btns.setEnabled(enabled) self.snap_checkbox.setEnabled(enabled) - self.radiobutton.setEnabled(enabled) + self._set_as_default_btn.setEnabled(enabled) self._poll_cb.setEnabled(enabled) def _on_system_cfg(self) -> None: - if self._dtype is DeviceType.XYStage: - if self._device not in self._mmc.getLoadedDevicesOfType(DeviceType.XYStage): - self._enable_and_update(False) - else: - self._enable_and_update(True) - - if self._dtype is DeviceType.Stage: - if self._device not in self._mmc.getLoadedDevicesOfType(DeviceType.Stage): - self._enable_and_update(False) - else: - self._enable_and_update(True) - - self._set_as_default() - - def _enable_and_update(self, enable: bool) -> None: - if enable: + if self._device in self._mmc.getLoadedDevicesOfType(self._dtype): self._enable_wdg(True) - self._update_position_label() + self._update_position_from_core() else: - self._readout.setText(f"{self._device} not loaded.") self._enable_wdg(False) + self._set_as_default() def _set_as_default(self) -> None: - current_xy = self._mmc.getXYStageDevice() - current_z = self._mmc.getFocusDevice() - if self._dtype is DeviceType.XYStage and current_xy == self._device: - self.radiobutton.setChecked(True) - elif self._dtype is DeviceType.Stage and current_z == self._device: - self.radiobutton.setChecked(True) - - def _on_radiobutton_toggled(self, state: bool) -> None: if self._dtype is DeviceType.XYStage: - if state: - self._mmc.setProperty("Core", "XYStage", self._device) - elif ( - not state - and len(self._mmc.getLoadedDevicesOfType(DeviceType.XYStage)) == 1 - ): - with signals_blocked(self.radiobutton): - self.radiobutton.setChecked(True) - else: - self._mmc.setProperty("Core", "XYStage", "") - + if self._mmc.getXYStageDevice() == self._device: + self._set_as_default_btn.setChecked(True) elif self._dtype is DeviceType.Stage: - if state: - self._mmc.setProperty("Core", "Focus", self._device) - elif ( - not state - and len(self._mmc.getLoadedDevicesOfType(DeviceType.Stage)) == 1 - ): - with signals_blocked(self.radiobutton): - self.radiobutton.setChecked(True) - else: - self._mmc.setProperty("Core", "Focus", "") + if self._mmc.getFocusDevice() == self._device: + self._set_as_default_btn.setChecked(True) + + def _on_radiobutton_toggled(self, state: bool) -> None: + prop = XY_STAGE if self._is_2axis else FOCUS + if state: + self._mmc.setProperty(CORE, prop, self._device) + elif len(self._mmc.getLoadedDevicesOfType(self._dtype)) == 1: + with signals_blocked(self._set_as_default_btn): + self._set_as_default_btn.setChecked(True) + else: + self._mmc.setProperty(CORE, prop, "") def _on_prop_changed(self, dev: str, prop: str, val: str) -> None: - if dev != "Core": + if ( + (dev != CORE) + or (self._is_2axis and prop != XY_STAGE) + or (not self._is_2axis and prop != FOCUS) + ): return - - if self._dtype is DeviceType.XYStage and prop == "XYStage": - with signals_blocked(self.radiobutton): - self.radiobutton.setChecked(val == self._device) - - if self._dtype is DeviceType.Stage and prop == "Focus": - with signals_blocked(self.radiobutton): - self.radiobutton.setChecked(val == self._device) + with signals_blocked(self._set_as_default_btn): + self._set_as_default_btn.setChecked(val == self._device) def _toggle_poll_timer(self, on: bool) -> None: - self._poll_timer.start() if on else self._poll_timer.stop() + if on: + if self._poll_timer_id is None: + self._poll_timer_id = self.startTimer(500) + else: + if self._poll_timer_id is not None: + self.killTimer(self._poll_timer_id) + self._poll_timer_id = None - def _update_position_label(self) -> None: - if ( - self._dtype is DeviceType.XYStage - and self._device in self._mmc.getLoadedDevicesOfType(DeviceType.XYStage) - ): - pos = self._mmc.getXYPosition(self._device) - p = ", ".join(str(round(x, 2)) for x in pos) - self._readout.setText(f"{self._device}: {p}") - elif ( - self._dtype is DeviceType.Stage - and self._device in self._mmc.getLoadedDevicesOfType(DeviceType.Stage) - ): - p = str(round(self._mmc.getPosition(self._device), 2)) - self._readout.setText(f"{self._device}: {p}") - - def _update_ttips(self) -> None: - coords = chain(zip(repeat(3), range(7)), zip(range(7), repeat(3))) - Y = {DeviceType.XYStage: "Y"}.get(self._dtype, "Z") - - btn_layout: QGridLayout = self._btns.layout() - for r, c in coords: - if item := btn_layout.itemAtPosition(r, c): - if (r, c) == (3, 3): - continue - if btn := item.widget(): - xmag, ymag = self.BTNS[f"{PREFIX}.{btn.text()}"][-2:] - if xmag: - btn.setToolTip(f"move X by {self._scale(xmag)} µm") - elif ymag: - btn.setToolTip(f"move {Y} by {self._scale(ymag)} µm") - - def _set_xy_visible(self) -> None: - if self._dtype is not DeviceType.XYStage: - btn_layout: QGridLayout = self._btns.layout() - for c in (0, 1, 2, 4, 5, 6): - if item := btn_layout.itemAtPosition(3, c): - item.widget().hide() - - def _set_visible_levels(self, levels: int) -> None: - """Hide upper-level stage buttons as desired. Levels must be between 1-3.""" - assert 1 <= levels <= 3, "levels must be between 1-3" - btn_layout: QGridLayout = self._btns.layout() - for btn in self._btns.findChildren(QPushButton): - btn.show() - if levels < 3: - # hide row/col 0, 6 - for r, c in product(range(7), (0, 6)): - if item := btn_layout.itemAtPosition(r, c): - item.widget().hide() - if item := btn_layout.itemAtPosition(c, r): - item.widget().hide() - if levels < 2: - # hide row/col 1, 5 - for r, c in product(range(1, 6), (1, 5)): - if item := btn_layout.itemAtPosition(r, c): - item.widget().hide() - if item := btn_layout.itemAtPosition(c, r): - item.widget().hide() + def timerEvent(self, event: QTimerEvent | None) -> None: + if event and event.timerId() == self._poll_timer_id: + self._update_position_from_core() + super().timerEvent(event) - def _on_click(self) -> None: - btn: QPushButton = self.sender() - xmag, ymag = self.BTNS[f"{PREFIX}.{btn.text()}"][-2:] + def _update_position_from_core(self) -> None: + if self._device not in self._mmc.getLoadedDevicesOfType(self._dtype): + return + if self._is_2axis: + x, y = self._mmc.getXYPosition(self._device) + lbl = f"X: {x:.01f} {self._Ylabel}: {y:.01f}" + else: + lbl = f"{self._Ylabel}: {self._mmc.getPosition(self._device):.01f}" + self._pos_label.setText(lbl) + def _on_move_requested(self, xmag: float, ymag: float) -> None: if self._invert_x.isChecked(): xmag *= -1 if self._invert_y.isChecked(): ymag *= -1 - - self._move_stage(self._scale(xmag), self._scale(ymag)) + self._move_stage(xmag, ymag) def _move_stage(self, x: float, y: float) -> None: - if self._dtype is DeviceType.XYStage: - self._mmc.setRelativeXYPosition(self._device, x, y) + try: + if self._is_2axis: + self._mmc.setRelativeXYPosition(self._device, x, y) + else: + self._mmc.setRelativePosition(self._device, y) + except Exception as e: + self._mmc.logMessage(f"Error moving stage: {e}") else: - self._mmc.setRelativePosition(self._device, y) - if self.snap_checkbox.isChecked(): - self._mmc.snap() - - def _scale(self, mag: int) -> float: - """Convert step mag of (1, 2, 3) to absolute XY units. - - Can be used to step 1x field of view, etc... - """ - return float(mag * self._step.value()) + if self.snap_checkbox.isChecked(): + self._mmc.snap() def _disconnect(self) -> None: self._mmc.events.propertyChanged.disconnect(self._on_prop_changed) self._mmc.events.systemConfigurationLoaded.disconnect(self._on_system_cfg) - if self._dtype is DeviceType.XYStage: + if self._is_2axis: event = self._mmc.events.XYStagePositionChanged - elif self._dtype is DeviceType.Stage: + else: event = self._mmc.events.stagePositionChanged - event.disconnect(self._update_position_label) + event.disconnect(self._update_position_from_core) diff --git a/tests/test_stage_widget.py b/tests/test_stage_widget.py index a5eba7ddf..4b5104d47 100644 --- a/tests/test_stage_widget.py +++ b/tests/test_stage_widget.py @@ -9,41 +9,39 @@ from pytestqt.qtbot import QtBot -def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus): +def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: # test XY stage stage_xy = StageWidget("XY", levels=3) qtbot.addWidget(stage_xy) assert global_mmcore.getXYStageDevice() == "XY" - assert stage_xy.radiobutton.isChecked() + assert stage_xy._set_as_default_btn.isChecked() global_mmcore.setProperty("Core", "XYStage", "") assert not global_mmcore.getXYStageDevice() - assert not stage_xy.radiobutton.isChecked() - stage_xy.radiobutton.setChecked(True) + assert not stage_xy._set_as_default_btn.isChecked() + stage_xy._set_as_default_btn.setChecked(True) assert global_mmcore.getXYStageDevice() == "XY" - assert stage_xy.radiobutton.isChecked() + assert stage_xy._set_as_default_btn.isChecked() stage_xy.setStep(5.0) assert stage_xy.step() == 5.0 - assert stage_xy._readout.text() == "XY: -0.0, -0.0" + assert stage_xy._pos_label.text() == "X: -0.0 Y: -0.0" x_pos = global_mmcore.getXPosition() y_pos = global_mmcore.getYPosition() assert x_pos == -0.0 assert y_pos == -0.0 - xy_up_3 = stage_xy._btns.layout().itemAtPosition(0, 3) + xy_up_3 = stage_xy._move_btns.layout().itemAtPosition(0, 3) xy_up_3.widget().click() assert ( (y_pos + (stage_xy.step() * 3)) - 1 < global_mmcore.getYPosition() < (y_pos + (stage_xy.step() * 3)) + 1 ) - label_x = round(global_mmcore.getXPosition(), 2) - label_y = round(global_mmcore.getYPosition(), 2) - assert stage_xy._readout.text() == f"XY: {label_x}, {label_y}" + assert stage_xy._pos_label.text() == "X: -0.0 Y: 15.0" - xy_left_1 = stage_xy._btns.layout().itemAtPosition(3, 2) + xy_left_1 = stage_xy._move_btns.layout().itemAtPosition(3, 2) global_mmcore.waitForDevice("XY") xy_left_1.widget().click() assert ( @@ -51,16 +49,13 @@ def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus): < global_mmcore.getXPosition() < (x_pos - stage_xy.step()) + 1 ) - label_x = round(global_mmcore.getXPosition(), 2) - label_y = round(global_mmcore.getYPosition(), 2) - assert stage_xy._readout.text() == f"XY: {label_x}, {label_y}" + assert stage_xy._pos_label.text() == "X: -5.0 Y: 15.0" - assert stage_xy._readout.text() != "XY: -0.0, -0.0" global_mmcore.waitForDevice("XY") global_mmcore.setXYPosition(0.0, 0.0) y_pos = global_mmcore.getYPosition() x_pos = global_mmcore.getXPosition() - assert stage_xy._readout.text() == "XY: -0.0, -0.0" + assert stage_xy._pos_label.text() == "X: -0.0 Y: -0.0" stage_xy.snap_checkbox.setChecked(True) with qtbot.waitSignal(global_mmcore.events.imageSnapped): @@ -75,37 +70,37 @@ def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus): qtbot.addWidget(stage_z1) assert global_mmcore.getFocusDevice() == "Z" - assert stage_z.radiobutton.isChecked() - assert not stage_z1.radiobutton.isChecked() + assert stage_z._set_as_default_btn.isChecked() + assert not stage_z1._set_as_default_btn.isChecked() global_mmcore.setProperty("Core", "Focus", "Z1") assert global_mmcore.getFocusDevice() == "Z1" - assert not stage_z.radiobutton.isChecked() - assert stage_z1.radiobutton.isChecked() - stage_z.radiobutton.setChecked(True) + assert not stage_z._set_as_default_btn.isChecked() + assert stage_z1._set_as_default_btn.isChecked() + stage_z._set_as_default_btn.setChecked(True) assert global_mmcore.getFocusDevice() == "Z" - assert stage_z.radiobutton.isChecked() - assert not stage_z1.radiobutton.isChecked() + assert stage_z._set_as_default_btn.isChecked() + assert not stage_z1._set_as_default_btn.isChecked() stage_z.setStep(15.0) assert stage_z.step() == 15.0 - assert stage_z._readout.text() == "Z: 0.0" + assert stage_z._pos_label.text() == "Z: 0.0" z_pos = global_mmcore.getPosition() assert z_pos == 0.0 - z_up_2 = stage_z._btns.layout().itemAtPosition(1, 3) + z_up_2 = stage_z._move_btns.layout().itemAtPosition(1, 3) z_up_2.widget().click() assert ( (z_pos + (stage_z.step() * 2)) - 1 < global_mmcore.getPosition() < (z_pos + (stage_z.step() * 2)) + 1 ) - assert stage_z._readout.text() == f"Z: {round(global_mmcore.getPosition(), 2)}" + assert stage_z._pos_label.text() == "Z: 30.0" global_mmcore.waitForDevice("Z") global_mmcore.setPosition(0.0) z_pos = global_mmcore.getPosition() - assert stage_z._readout.text() == "Z: 0.0" + assert stage_z._pos_label.text() == "Z: 0.0" stage_z.snap_checkbox.setChecked(True) with qtbot.waitSignal(global_mmcore.events.imageSnapped): @@ -114,25 +109,25 @@ def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus): # disconnect assert global_mmcore.getFocusDevice() == "Z" - assert stage_z.radiobutton.isChecked() - assert not stage_z1.radiobutton.isChecked() + assert stage_z._set_as_default_btn.isChecked() + assert not stage_z1._set_as_default_btn.isChecked() stage_z._disconnect() stage_z1._disconnect() # once disconnected, core changes shouldn't call out to the widget global_mmcore.setProperty("Core", "Focus", "Z1") - assert stage_z.radiobutton.isChecked() - assert not stage_z1.radiobutton.isChecked() + assert stage_z._set_as_default_btn.isChecked() + assert not stage_z1._set_as_default_btn.isChecked() -def test_invert_axis(qtbot: QtBot, global_mmcore: CMMCorePlus): +def test_invert_axis(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: stage_xy = StageWidget("XY", levels=3) qtbot.addWidget(stage_xy) assert not stage_xy._invert_x.isHidden() assert not stage_xy._invert_y.isHidden() - xy_up_3 = stage_xy._btns.layout().itemAtPosition(0, 3) - xy_left_1 = stage_xy._btns.layout().itemAtPosition(3, 2) + xy_up_3 = stage_xy._move_btns.layout().itemAtPosition(0, 3) + xy_left_1 = stage_xy._move_btns.layout().itemAtPosition(3, 2) stage_xy.setStep(15.0) @@ -155,9 +150,8 @@ def test_invert_axis(qtbot: QtBot, global_mmcore: CMMCorePlus): qtbot.addWidget(stage_z) assert stage_z._invert_x.isHidden() - assert stage_z._invert_y.isHidden() - z_up_2 = stage_z._btns.layout().itemAtPosition(1, 3) + z_up_2 = stage_z._move_btns.layout().itemAtPosition(1, 3) z_up_2.widget().click() assert global_mmcore.getPosition() == 20.0 From 825c199a9cffb9256f2cc86524a57126839d17c1 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Wed, 24 Jul 2024 15:37:07 -0400 Subject: [PATCH 15/38] feat: reusable single-well calibration widget for plate calibration widget (#353) * feat: wip * feat: WellCalibrationWidget * fix: remove show_critical_message * tl changes * udpates * remove print * fix clearing behavior * fix selection mode * test: add tests * test: fix qtpy * pragmas --------- Co-authored-by: Talley Lambert --- examples/temp/well_calibration_widget.py | 26 ++ src/pymmcore_widgets/hcs/__init__.py | 1 + src/pymmcore_widgets/hcs/_util.py | 77 +++++ .../hcs/_well_calibration_widget.py | 312 ++++++++++++++++++ .../hcs/icons/circle-center.svg | 21 ++ .../hcs/icons/circle-edges.svg | 33 ++ .../hcs/icons/square-center.svg | 21 ++ .../hcs/icons/square-edges.svg | 39 +++ .../hcs/icons/square-vertices.svg | 27 ++ .../test_hcs/test_well_calibration_widget.py | 162 +++++++++ 10 files changed, 719 insertions(+) create mode 100644 examples/temp/well_calibration_widget.py create mode 100644 src/pymmcore_widgets/hcs/__init__.py create mode 100644 src/pymmcore_widgets/hcs/_util.py create mode 100644 src/pymmcore_widgets/hcs/_well_calibration_widget.py create mode 100644 src/pymmcore_widgets/hcs/icons/circle-center.svg create mode 100644 src/pymmcore_widgets/hcs/icons/circle-edges.svg create mode 100644 src/pymmcore_widgets/hcs/icons/square-center.svg create mode 100644 src/pymmcore_widgets/hcs/icons/square-edges.svg create mode 100644 src/pymmcore_widgets/hcs/icons/square-vertices.svg create mode 100644 tests/test_hcs/test_well_calibration_widget.py diff --git a/examples/temp/well_calibration_widget.py b/examples/temp/well_calibration_widget.py new file mode 100644 index 000000000..bddccff1a --- /dev/null +++ b/examples/temp/well_calibration_widget.py @@ -0,0 +1,26 @@ +from pymmcore_plus import CMMCorePlus +from qtpy.QtWidgets import QApplication + +from pymmcore_widgets import StageWidget +from pymmcore_widgets.hcs._well_calibration_widget import WellCalibrationWidget + +mmc = CMMCorePlus.instance() +mmc.loadSystemConfiguration() + +app = QApplication([]) + +s = StageWidget("XY") +s.show() +c = WellCalibrationWidget(mmcore=mmc) + + +@c.calibrationChanged.connect +def _on_calibration_changed(calibrated: bool) -> None: + if calibrated: + print("Calibration changed! New center:", c.wellCenter()) + + +c.setCircularWell(True) +c.show() + +app.exec() diff --git a/src/pymmcore_widgets/hcs/__init__.py b/src/pymmcore_widgets/hcs/__init__.py new file mode 100644 index 000000000..0ebac13cb --- /dev/null +++ b/src/pymmcore_widgets/hcs/__init__.py @@ -0,0 +1 @@ +"""Calibration widget.""" diff --git a/src/pymmcore_widgets/hcs/_util.py b/src/pymmcore_widgets/hcs/_util.py new file mode 100644 index 000000000..4430c67f3 --- /dev/null +++ b/src/pymmcore_widgets/hcs/_util.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import Iterable + +import numpy as np + + +def find_circle_center( + coords: Iterable[tuple[float, float]], +) -> tuple[float, float, float]: + """Calculate the center of a circle passing through three or more points. + + This function uses the least squares method to find the center of a circle + that passes through the given coordinates. The input coordinates should be + an iterable of 2D points (x, y). + + Returns + ------- + tuple : (x, y, radius) + The center of the circle and the radius of the circle. + """ + points = np.array(coords) + if points.ndim != 2 or points.shape[1] != 2: # pragma: no cover + raise ValueError("Invalid input coordinates") + if len(points) < 3: # pragma: no cover + raise ValueError("At least 3 points are required") + + # Prepare the matrices for least squares + A = np.hstack((points, np.ones((points.shape[0], 1)))) + B = np.sum(points**2, axis=1).reshape(-1, 1) + + # Solve the least squares problem + params, _residuals, rank, s = np.linalg.lstsq(A, B, rcond=None) + + if rank < 3: # pragma: no cover + raise ValueError("The points are collinear or nearly collinear") + + # Extract the circle parameters + x = params[0][0] / 2 + y = params[1][0] / 2 + + # radius, if needed + r_squared = params[2][0] + x**2 + y**2 + radius = np.sqrt(r_squared) + + return (x, y, radius) + + +def find_rectangle_center( + coords: Iterable[tuple[float, float]], +) -> tuple[float, float, float, float]: + """Find the center of a rectangle/square well from 2 or more points. + + Returns + ------- + tuple : (x, y, width, height) + The center of the rectangle, width, and height. + """ + points = np.array(coords) + + if points.ndim != 2 or points.shape[1] != 2: # pragma: no cover + raise ValueError("Invalid input coordinates") + if len(points) < 2: # pragma: no cover + raise ValueError("At least 2 points are required") + + # Find the min and max x and y values + x_min, y_min = points.min(axis=0) + x_max, y_max = points.max(axis=0) + + # Calculate the center of the rectangle + x = (x_min + x_max) / 2 + y = (y_min + y_max) / 2 + + # Calculate the width and height of the rectangle + width = x_max - x_min + height = y_max - y_min + return (x, y, width, height) diff --git a/src/pymmcore_widgets/hcs/_well_calibration_widget.py b/src/pymmcore_widgets/hcs/_well_calibration_widget.py new file mode 100644 index 000000000..582ccb169 --- /dev/null +++ b/src/pymmcore_widgets/hcs/_well_calibration_widget.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Iterator, NamedTuple, cast + +from fonticon_mdi6 import MDI6 +from pymmcore_plus import CMMCorePlus +from qtpy.QtCore import QItemSelection, QSize, Qt, Signal +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import ( + QComboBox, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) +from superqt.fonticon import icon +from superqt.utils import signals_blocked + +from ._util import find_circle_center, find_rectangle_center + +COMBO_ROLE = Qt.ItemDataRole.UserRole + 1 +ICON_PATH = Path(__file__).parent / "icons" +ONE_CIRCLE = QIcon(str(ICON_PATH / "circle-center.svg")) +ONE_SQUARE = QIcon(str(ICON_PATH / "square-center.svg")) +TWO = QIcon(str(ICON_PATH / "square-vertices.svg")) +THREE = QIcon(str(ICON_PATH / "circle-edges.svg")) +FOUR = QIcon(str(ICON_PATH / "square-edges.svg")) +NON_CALIBRATED_ICON = MDI6.circle +CALIBRATED_ICON = MDI6.check_circle +ICON_SIZE = QSize(30, 30) +YELLOW = Qt.GlobalColor.yellow +GREEN = Qt.GlobalColor.green + + +class Mode(NamedTuple): + """Calibration mode.""" + + text: str + points: int + icon: QIcon | None = None + + +# mapping of Circular well -> [Modes] +MODES: dict[bool, list[Mode]] = { + True: [ + Mode("1 Center point", 1, ONE_CIRCLE), + Mode("3 Edge points", 3, THREE), + ], + False: [ + Mode("1 Center point", 1, ONE_SQUARE), + Mode("2 Corners", 2, TWO), + Mode("4 Edge points", 4, FOUR), + ], +} + + +class _CalibrationModeWidget(QComboBox): + """Widget to select the calibration mode.""" + + modeChanged = Signal(object) + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self._is_circular = False + self.currentIndexChanged.connect(self._on_value_changed) + + def _on_value_changed(self, index: int) -> None: + """Emit the selected mode with valueChanged signal.""" + self.modeChanged.emit(self.currentMode()) + + def isCircularMode(self) -> bool: + """Return True if the well is circular.""" + return self._is_circular + + def setCircularMode(self, circular: bool) -> None: + self._is_circular = bool(circular) + with signals_blocked(self): + self.clear() + for idx, mode in enumerate(MODES[self._is_circular]): + self.addItem(mode.icon, mode.text) + self.setItemData(idx, mode, COMBO_ROLE) + self.modeChanged.emit(self.currentMode()) + + def currentMode(self) -> Mode: + """Return the selected calibration mode.""" + return cast(Mode, self.itemData(self.currentIndex(), COMBO_ROLE)) + + +class _CalibrationTable(QTableWidget): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(0, 2, parent) + + self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) + self.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + + hdr = self.horizontalHeader() + hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignCenter) + hdr.setSectionResizeMode(hdr.ResizeMode.Stretch) + + self.setHorizontalHeaderLabels(["X [mm]", "Y [mm]"]) + self.selectionModel().selectionChanged.connect(self._on_selection_changed) + + def _on_selection_changed( + self, selected: QItemSelection, deselected: QItemSelection + ) -> None: + # ensure something is always selected + if selected.count() == 0 and deselected.count() > 0: + sel_model = self.selectionModel() + for item in deselected.indexes(): + sel_model.select(item, sel_model.SelectionFlag.Select) + + def positions(self) -> Iterator[tuple[int, float, float]]: + """Return the list of non-null (row, x, y) points.""" + for row in range(self.rowCount()): + if ( + (x_item := self.item(row, 0)) + and (y_item := self.item(row, 1)) + and (x_text := x_item.text()) + and (y_text := y_item.text()) + ): + yield (row, float(x_text), float(y_text)) + + def set_selected(self, x: float, y: float) -> None: + """Assign (x, y) to the currently selected row in the table.""" + if not (indices := self.selectedIndexes()): + return # pragma: no cover + + selected_row = indices[0].row() + for row, *p in self.positions(): + if p == [x, y] and row != selected_row: + QMessageBox.critical( + self, + "Duplicate position", + f"Position ({x}, {y}) is already in the list.", + ) + return + + self._set_row(selected_row, x, y) + if selected_row < self.rowCount() - 1: + self.setCurrentCell(selected_row + 1, 0) + + def _set_row(self, row: int, x: str | float, y: str | float) -> None: + """Emit only one itemChanged signal when setting the item.""" + itemx = QTableWidgetItem(f"{x:.2f}") + itemx.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + itemy = QTableWidgetItem(f"{y:.2f}") + itemy.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + + with signals_blocked(self): + self.setItem(row, 0, itemx) + self.setItem(row, 1, itemy) + + def clear_selected(self) -> None: + """Remove the selected position from the table.""" + if items := self.selectedItems(): + with signals_blocked(self): + for item in items: + item.setText("") + self.itemChanged.emit(item) + + def clear_all(self) -> None: + self.resetRowCount(self.rowCount()) + + def resetRowCount(self, num: int) -> None: + with signals_blocked(self): + self.clearContents() + self.setRowCount(num) + # select the first row + self.setCurrentCell(0, 0) + self.itemChanged.emit(self.item(0, 0)) + + +class WellCalibrationWidget(QWidget): + calibrationChanged = Signal(bool) + + def __init__( + self, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__(parent) + + self._mmc = mmcore or CMMCorePlus.instance() + self._well_center: tuple[float, float] | None = None + + # WIDGETS ------------------------------------------------------------- + + # Well label + self._well_label = QLabel("Well A1") + font = self._well_label.font() + font.setBold(True) + font.setPixelSize(16) + self._well_label.setFont(font) + + # Icon for calibration status + self._calibration_icon = QLabel() + icn = icon(NON_CALIBRATED_ICON, color=YELLOW) + self._calibration_icon.setPixmap(icn.pixmap(ICON_SIZE)) + + # calibration mode + self._calibration_mode_wdg = _CalibrationModeWidget(self) + + # calibration table and buttons + self._table = _CalibrationTable(self) + + # add and remove buttons + self._set_button = QPushButton("Set") + self._clear_button = QPushButton("Clear") + self._clear_all_button = QPushButton("Clear All") + + # LAYOUT -------------------------------------------------------------- + + labels = QHBoxLayout() + labels.setContentsMargins(0, 0, 0, 0) + labels.addWidget(self._calibration_icon) + labels.addWidget(self._well_label, 1) + + mode_row = QHBoxLayout() + mode_row.addWidget(QLabel("Method:")) + mode_row.addWidget(self._calibration_mode_wdg, 1) + + remove_btns = QHBoxLayout() + remove_btns.setContentsMargins(0, 0, 0, 0) + remove_btns.addWidget(self._clear_button) + remove_btns.addWidget(self._clear_all_button) + + main_layout = QVBoxLayout(self) + main_layout.setSpacing(5) + main_layout.addLayout(labels) + main_layout.addLayout(mode_row) + main_layout.addWidget(self._table) + main_layout.addWidget(self._set_button) + main_layout.addLayout(remove_btns) + + # CONNECTIONS --------------------------------------------------------- + + self._calibration_mode_wdg.modeChanged.connect(self._on_mode_changed) + self._set_button.clicked.connect(self._on_set_clicked) + self._clear_button.clicked.connect(self._table.clear_selected) + self._clear_all_button.clicked.connect(self._table.clear_all) + self._table.itemChanged.connect(self._validate_calibration) + + def wellCenter(self) -> tuple[float, float] | None: + """Return the center of the well, or None if not calibrated.""" + return self._well_center + + def setCircularWell(self, circular: bool) -> None: + """Update the calibration widget for circular or rectangular wells.""" + self._calibration_mode_wdg.setCircularMode(circular) + + def circularWell(self) -> bool: + """Return True if the well is circular.""" + return self._calibration_mode_wdg.isCircularMode() + + def _on_set_clicked(self) -> None: + x, y = self._mmc.getXYPosition() + self._table.set_selected(round(x, 2), round(y, 2)) + + def _on_mode_changed(self, mode: Mode) -> None: + """Update the rows in the calibration table.""" + self._table.resetRowCount(mode.points) + self._set_well_center(None) + + def _set_well_center(self, center: tuple[float, float] | None) -> None: + """Set the calibration icon and emit the calibrationChanged signal.""" + if self._well_center == center: + return + + self._well_center = center + if center is None: + icn = icon(NON_CALIBRATED_ICON, color=YELLOW) + else: + icn = icon(CALIBRATED_ICON, color=GREEN) + self._calibration_icon.setPixmap(icn.pixmap(ICON_SIZE)) + self.calibrationChanged.emit(center is not None) + + def _validate_calibration(self) -> None: + """Validate the calibration points added to the table.""" + # get the current (x, y) positions in the table + points = [p[1:] for p in self._table.positions()] + needed_points = self._calibration_mode_wdg.currentMode().points + + # if the number of points is not yet satisfied, do nothing + if len(points) < needed_points: + self._set_well_center(None) + return + + # if the number of points is 1, well is already calibrated + if needed_points == 1: + self._set_well_center(points[0]) + return + + # if the number of points is correct, try to calculate the calibration + try: + # TODO: allow additional sanity checks for min/max radius, width/height + if self.circularWell(): + x, y, radius = find_circle_center(points) + else: + x, y, width, height = find_rectangle_center(points) + except Exception as e: # pragma: no cover + self._set_well_center(None) + QMessageBox.critical( + self, + "Calibration error", + f"Could not calculate the center of the well.\n\n{e}", + ) + else: + self._set_well_center((x, y)) diff --git a/src/pymmcore_widgets/hcs/icons/circle-center.svg b/src/pymmcore_widgets/hcs/icons/circle-center.svg new file mode 100644 index 000000000..afebb0d0e --- /dev/null +++ b/src/pymmcore_widgets/hcs/icons/circle-center.svg @@ -0,0 +1,21 @@ + + + + + diff --git a/src/pymmcore_widgets/hcs/icons/circle-edges.svg b/src/pymmcore_widgets/hcs/icons/circle-edges.svg new file mode 100644 index 000000000..0140fd998 --- /dev/null +++ b/src/pymmcore_widgets/hcs/icons/circle-edges.svg @@ -0,0 +1,33 @@ + + + + + + + diff --git a/src/pymmcore_widgets/hcs/icons/square-center.svg b/src/pymmcore_widgets/hcs/icons/square-center.svg new file mode 100644 index 000000000..129de460f --- /dev/null +++ b/src/pymmcore_widgets/hcs/icons/square-center.svg @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/src/pymmcore_widgets/hcs/icons/square-edges.svg b/src/pymmcore_widgets/hcs/icons/square-edges.svg new file mode 100644 index 000000000..1c4e8c285 --- /dev/null +++ b/src/pymmcore_widgets/hcs/icons/square-edges.svg @@ -0,0 +1,39 @@ + + + + + + + + diff --git a/src/pymmcore_widgets/hcs/icons/square-vertices.svg b/src/pymmcore_widgets/hcs/icons/square-vertices.svg new file mode 100644 index 000000000..c2946a91d --- /dev/null +++ b/src/pymmcore_widgets/hcs/icons/square-vertices.svg @@ -0,0 +1,27 @@ + + + + + + diff --git a/tests/test_hcs/test_well_calibration_widget.py b/tests/test_hcs/test_well_calibration_widget.py new file mode 100644 index 000000000..ac91ddf61 --- /dev/null +++ b/tests/test_hcs/test_well_calibration_widget.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from pymmcore_widgets.hcs._well_calibration_widget import ( + COMBO_ROLE, + MODES, + WellCalibrationWidget, +) + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + from pytestqt.qtbot import QtBot + from qtpy.QtWidgets import QLabel + +YELLOW = "#ffff00" +GREEN = "#00ff00" + + +def get_icon_color(qlabel: QLabel): + pixmap = qlabel.pixmap() + image = pixmap.toImage() + pixel_color = image.pixelColor(image.width() // 2, image.height() // 2) + return pixel_color.name() + + +def test_well_calibration_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: + """Test the WellCalibrationWidget.""" + wdg = WellCalibrationWidget(mmcore=global_mmcore) + qtbot.addWidget(wdg) + wdg.show() + + # make sure the table has the correct number of rows and columns + assert not wdg._table.rowCount() + assert not list(wdg._table.positions()) + assert wdg._table.columnCount() == 2 + + # get the icon color + assert get_icon_color(wdg._calibration_icon) == YELLOW + + +@pytest.mark.parametrize("circular", [True, False]) +def test_well_calibration_widget_modes( + qtbot: QtBot, global_mmcore: CMMCorePlus, circular: bool +) -> None: + """Test the WellCalibrationWidget.""" + wdg = WellCalibrationWidget(mmcore=global_mmcore) + qtbot.addWidget(wdg) + wdg.show() + + # set circular well property + wdg.setCircularWell(circular) + assert wdg.circularWell() == circular + # get the modes form the combobox + combo = wdg._calibration_mode_wdg + modes = [combo.itemData(i, COMBO_ROLE) for i in range(combo.count())] + # make sure the modes are correct + assert modes == MODES[circular] + # make sure that the correct number of rows are displayed when the mode is changed + for idx, mode in enumerate(modes): + # set the mode + combo.setCurrentIndex(idx) + # get the number of rows + assert wdg._table.rowCount() == mode.points + + +def test_well_calibration_widget_positions( + qtbot: QtBot, global_mmcore: CMMCorePlus +) -> None: + """Test the WellCalibrationWidget.""" + wdg = WellCalibrationWidget(mmcore=global_mmcore) + qtbot.addWidget(wdg) + wdg.show() + + wdg.setCircularWell(True) + + assert get_icon_color(wdg._calibration_icon) == YELLOW + + assert wdg._table.rowCount() == 1 + assert not list(wdg._table.positions()) + + assert wdg._mmc.getXPosition() == 0 + assert wdg._mmc.getYPosition() == 0 + + # make sure nothing happens when the set button is clicked multiple times if + # the number of rows is already the maximum + for _ in range(2): + wdg._on_set_clicked() + assert wdg._table.rowCount() == 1 + assert list(wdg._table.positions()) == [(0, 0, 0)] + + # the well should be calibrated and icon should be green + assert get_icon_color(wdg._calibration_icon) == GREEN + + # set 3 points mode + wdg._calibration_mode_wdg.setCurrentIndex(1) + assert wdg._table.rowCount() == 3 + assert not list(wdg._table.positions()) + + # icon should be yellow since we changed the mode + assert get_icon_color(wdg._calibration_icon) == YELLOW + + wdg._on_set_clicked() + assert wdg._table.rowCount() == 3 + assert list(wdg._table.positions()) == [(0, 0, 0)] + + # make sure you cannot add the same position twice + with patch("qtpy.QtWidgets.QMessageBox.critical") as mock_critical: + wdg._on_set_clicked() + mock_critical.assert_called_once() + + # add 2 more positions + global_mmcore.setXYPosition(10, 10) + global_mmcore.waitForSystem() + wdg._on_set_clicked() + assert (1, 10, 10) in list(wdg._table.positions()) + + # well still not calibrated + assert get_icon_color(wdg._calibration_icon) == YELLOW + + global_mmcore.setXYPosition(10, -10) + global_mmcore.waitForSystem() + with qtbot.waitSignal(wdg.calibrationChanged): + wdg._on_set_clicked() + assert (2, 10, -10) in list(wdg._table.positions()) + + # well should be calibrated and icon should be green + center = wdg.wellCenter() + assert center is not None + assert (round(center[0]), round(center[1])) == (10, 0) + assert get_icon_color(wdg._calibration_icon) == GREEN + + +def test_well_calibration_widget_clear( + qtbot: QtBot, global_mmcore: CMMCorePlus +) -> None: + """Test the WellCalibrationWidget.""" + wdg = WellCalibrationWidget(mmcore=global_mmcore) + qtbot.addWidget(wdg) + wdg.show() + + wdg.setCircularWell(True) + wdg._calibration_mode_wdg.setCurrentIndex(1) + + values = [(0, 0), (10, 10), (10, -10)] + for r in range(3): + wdg._table.selectRow(r) + wdg._table.set_selected(*values[r]) + + assert len(list(wdg._table.positions())) == 3 + + # test the clear button + wdg._table.selectRow(1) + wdg._clear_button.click() + assert len(list(wdg._table.positions())) == 2 + + # test clear all + wdg._clear_all_button.click() + assert not list(wdg._table.positions()) From a7af7417fe94b30eb0e36c78588f8279f9294e49 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 4 Aug 2024 21:15:21 -0400 Subject: [PATCH 16/38] feat: plate calibration widget (#355) * wip on plate calibration * working example * move logic * add affine * updates * info * style(pre-commit.ci): auto fixes [...] * add info * tests: add test * style(pre-commit.ci): auto fixes [...] * fix lint * fix test * change fix * lint * fix unit --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- examples/temp/plate_calibration_widget.py | 19 ++ .../hcs/_plate_calibration_widget.py | 291 ++++++++++++++++++ .../hcs/_well_calibration_widget.py | 46 +-- .../useq_widgets/_well_plate_widget.py | 77 ++++- .../test_well_calibration_widget.py | 0 .../hcs/test_well_plate_calibration_widget.py | 66 ++++ 6 files changed, 460 insertions(+), 39 deletions(-) create mode 100644 examples/temp/plate_calibration_widget.py create mode 100644 src/pymmcore_widgets/hcs/_plate_calibration_widget.py rename tests/{test_hcs => hcs}/test_well_calibration_widget.py (100%) create mode 100644 tests/hcs/test_well_plate_calibration_widget.py diff --git a/examples/temp/plate_calibration_widget.py b/examples/temp/plate_calibration_widget.py new file mode 100644 index 000000000..23804c076 --- /dev/null +++ b/examples/temp/plate_calibration_widget.py @@ -0,0 +1,19 @@ +from pymmcore_plus import CMMCorePlus +from qtpy.QtWidgets import QApplication + +from pymmcore_widgets import StageWidget +from pymmcore_widgets.hcs._plate_calibration_widget import PlateCalibrationWidget + +mmc = CMMCorePlus.instance() +mmc.loadSystemConfiguration() + +app = QApplication([]) + +s = StageWidget("XY") +s.show() + +wdg = PlateCalibrationWidget(mmcore=mmc) +wdg.setPlate("96-well") +wdg.show() + +app.exec() diff --git a/src/pymmcore_widgets/hcs/_plate_calibration_widget.py b/src/pymmcore_widgets/hcs/_plate_calibration_widget.py new file mode 100644 index 000000000..7af736770 --- /dev/null +++ b/src/pymmcore_widgets/hcs/_plate_calibration_widget.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +from contextlib import suppress +from typing import Mapping + +import numpy as np +import useq +from pymmcore_plus import CMMCorePlus +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import ( + QHBoxLayout, + QLabel, + QPushButton, + QStackedWidget, + QStyle, + QVBoxLayout, + QWidget, +) +from superqt.fonticon import icon + +from pymmcore_widgets.hcs._well_calibration_widget import ( + CALIBRATED_ICON, + GREEN, + WellCalibrationWidget, +) +from pymmcore_widgets.useq_widgets._well_plate_widget import WellPlateView + + +class PlateCalibrationWidget(QWidget): + """Widget to calibrate a well plate. + + Provides a view of the well plate with the ability to select and calibrate + individual wells. + """ + + calibrationChanged = Signal(bool) + + def __init__( + self, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__(parent) + + self._mmc = mmcore or CMMCorePlus.instance() + self._current_plate: useq.WellPlate | None = None + # minimum number of wells required to be calibrated + # before the plate is considered calibrated + self._min_wells_required: int = 3 + + # mapping of well index (r, c) to well center (x, y) + self._calibrated_wells: dict[tuple[int, int], tuple[float, float]] = {} + + # WIDGETS ------------------------------------------------------------ + + self._plate_view = WellPlateView() + self._plate_view.setDragMode(WellPlateView.DragMode.NoDrag) + self._plate_view.setSelectionMode(WellPlateView.SelectionMode.SingleSelection) + self._plate_view.setSelectedColor(Qt.GlobalColor.yellow) + + self._test_btn = QPushButton("Test Well", self) + self._test_btn.setEnabled(False) + self._test_btn.hide() + + # mapping of well index (r, c) to calibration widget + # these are created on demand in _get_or_create_well_calibration_widget + self._calibration_widgets: dict[tuple[int, int], WellCalibrationWidget] = {} + self._calibration_widget_stack = QStackedWidget() + + self._info = QLabel("Please calibrate at least three wells.") + self._info_icon = QLabel() + self._update_info(None) + + # LAYOUT ------------------------------------------------------------- + + right_layout = QVBoxLayout() + right_layout.setContentsMargins(6, 0, 0, 0) + right_layout.addWidget(self._calibration_widget_stack) + # right_layout.addWidget(SeparatorWidget()) + # right_layout.addWidget(self._test_btn) + right_layout.addStretch() + + top = QHBoxLayout() + top.addWidget(self._plate_view, 1) + top.addLayout(right_layout) + + info_layout = QHBoxLayout() + info_layout.addWidget(self._info_icon, 0) + info_layout.addWidget(self._info, 1) + + main = QVBoxLayout(self) + main.addLayout(top) + main.addLayout(info_layout) + + # CONNECTIONS --------------------------------------------------------- + + self._plate_view.selectionChanged.connect(self._on_plate_selection_changed) + + def setPlate(self, plate: str | useq.WellPlate | useq.WellPlatePlan) -> None: + """Set the plate to be calibrated.""" + if isinstance(plate, str): + plate = useq.WellPlate.from_str(plate) + elif isinstance(plate, useq.WellPlatePlan): + plate = plate.plate + + self._current_plate = plate + self._plate_view.drawPlate(plate) + + # clear existing calibration widgets + while self._calibration_widgets: + wdg = self._calibration_widgets.popitem()[1] + self._calibration_widget_stack.removeWidget(wdg) + wdg.deleteLater() + + # Select A1 well + self._plate_view.setSelectedIndices([(0, 0)]) + + def platePlan(self) -> useq.WellPlatePlan: + """Return the plate plan with calibration information.""" + a1_center_xy = (0.0, 0.0) + rotation: float = 0.0 + if (osr := self._origin_spacing_rotation()) is not None: + a1_center_xy, (unit_x, unit_y), rotation = osr + return useq.WellPlatePlan( + plate=self._current_plate, + a1_center_xy=a1_center_xy, + rotation=rotation, + ) + + # ----------------------------------------------- + + def _origin_spacing_rotation( + self, + ) -> tuple[tuple[float, float], tuple[float, float], float] | None: + """Return the origin, scale, and rotation of the plate. + + If the plate is not fully calibrated, returns None. + + The units are a little confusing here, but are chosen to match the units in + the useq.WellPlatePlan class. The origin is in µm, the well spacing is in mm. + + Returns + ------- + origin : tuple[float, float] + The stage coordinates in µm of the center of well A1 (top-left corner). + well_spacing : tuple[float, float] + The center-to-center distance in mm (pitch) between wells in the x and y + directions. + rotation : float + a1_center_xy : tuple[float, float] + The rotation angle in degrees (anti-clockwise) of the plate. + """ + if not len(self._calibrated_wells) >= self._min_wells_required: + # not enough wells calibrated + return None + + try: + params = well_coords_affine(self._calibrated_wells) + except ValueError: + # collinear points + return None + + a, b, ty, c, d, tx = params + unit_y = np.hypot(a, c) / 1000 # convert to mm + unit_x = np.hypot(b, d) / 1000 # convert to mm + rotation = round(np.rad2deg(np.arctan2(c, a)), 2) + + return (round(tx, 4), round(ty, 4)), (unit_x, unit_y), rotation + + def _get_or_create_well_calibration_widget( + self, idx: tuple[int, int] + ) -> WellCalibrationWidget: + """Create or return the calibration widget for the given well index.""" + if not self._current_plate: # pragma: no cover + raise ValueError("No plate set.") + if idx in self._calibration_widgets: + return self._calibration_widgets[idx] + + self._calibration_widgets[idx] = wdg = WellCalibrationWidget(self, self._mmc) + wdg.layout().setContentsMargins(0, 0, 0, 0) + + # set calibration widget well name, and circular well state + well_name = self._current_plate.all_well_names[idx] + wdg.well_label.setText(well_name) + if self._current_plate: + wdg.setCircularWell(self._current_plate.circular_wells) + + wdg.calibrationChanged.connect(self._on_well_calibration_changed) + self._calibration_widget_stack.addWidget(wdg) + return wdg + + def _current_calibration_widget(self) -> WellCalibrationWidget | None: + return self._calibration_widget_stack.currentWidget() # type: ignore + + def _on_plate_selection_changed(self) -> None: + """A well has been selected in the plate view.""" + if not (idx := self._selected_well_index()): + return + + # create/activate a well calibration widget for the selected well + with suppress(ValueError): + well_calib_wdg = self._get_or_create_well_calibration_widget(idx) + self._calibration_widget_stack.setCurrentWidget(well_calib_wdg) + + # enable/disable test button + self._test_btn.setEnabled(idx in self._calibrated_wells) + + def _on_well_calibration_changed(self, calibrated: bool) -> None: + """The current well calibration state has been changed.""" + self._test_btn.setEnabled(calibrated) + if idx := self._selected_well_index(): + # update the color of the well in the plate view accordingly + if calibrated and (well_calib_wdg := self._current_calibration_widget()): + if center := well_calib_wdg.wellCenter(): + self._calibrated_wells[idx] = center + self._plate_view.setWellColor(*idx, Qt.GlobalColor.green) + else: + self._calibrated_wells.pop(idx, None) + self._plate_view.setWellColor(*idx, None) + + osr = self._origin_spacing_rotation() + fully_calibrated = osr is not None + self._update_info(osr) + self.calibrationChanged.emit(fully_calibrated) + + def _update_info( + self, osr: tuple[tuple[float, float], tuple[float, float], float] | None + ) -> None: + style = self.style() + if osr is not None: + txt = "Plate calibrated." + ico = icon(CALIBRATED_ICON, color=GREEN) + if self._current_plate is not None: + origin, spacing, rotation = osr + spacing_diff = abs(spacing[0] - self._current_plate.well_spacing[0]) + # if spacing is more than 5% different from the plate spacing... + if spacing_diff > 0.05 * self._current_plate.well_spacing[0]: + txt += ( + " Expected well spacing of " + f"{self._current_plate.well_spacing[0]:.2f} mm, " + f"calibrated at {spacing[0]:.2f}" + ) + ico = style.standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning) + txt += "
" + txt += f"\nA1 Center [mm]: ({origin[0]/1000:.2f}, {origin[1]/1000:.2f}), " + txt += f"Well Spacing [mm]: ({spacing[0]:.2f}, {spacing[1]:.2f}), " + txt += f"Rotation: {rotation}°" + elif len(self._calibrated_wells) < self._min_wells_required: + txt = f"Please calibrate at least {self._min_wells_required} wells." + ico = style.standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation) + else: + txt = "Could not calibrate. Ensure points are not collinear." + ico = style.standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning) + self._info_icon.setPixmap(ico.pixmap(42)) + self._info.setText(txt) + + def _selected_well_index(self) -> tuple[int, int] | None: + if selected := self._plate_view.selectedIndices(): + return selected[0] + return None + + +def well_coords_affine( + index_coordinates: Mapping[tuple[int, int], tuple[float, float]], +) -> tuple[float, float, float, float, float, float]: + """Return best-fit transformation that maps well plate indices to world coordinates. + + Parameters + ---------- + index_coordinates : Mapping[tuple[int, int], tuple[float, float]] + A mapping of grid indices to world coordinates. + + Returns + ------- + np.ndarray: The affine transformation matrix of shape (3, 3). + """ + A: list[list[int]] = [] + B: list[float] = [] + for (row, col), (x, y) in index_coordinates.items(): + # well plate indices go up in row as we go down in y + # so we have to negate the row to get the correct transformation + A.append([-row, col, 1, 0, 0, 0]) + A.append([0, 0, 0, -row, col, 1]) + # row corresponds to y, col corresponds to x + B.extend((y, x)) + + # Solve the least squares problem to find the affine transformation parameters + params, _, rank, _ = np.linalg.lstsq(np.array(A), np.array(B), rcond=None) + + if rank != 6: + raise ValueError("Underdetermined system of equations. Are points collinear?") + + return tuple(params) diff --git a/src/pymmcore_widgets/hcs/_well_calibration_widget.py b/src/pymmcore_widgets/hcs/_well_calibration_widget.py index 582ccb169..ba8e65118 100644 --- a/src/pymmcore_widgets/hcs/_well_calibration_widget.py +++ b/src/pymmcore_widgets/hcs/_well_calibration_widget.py @@ -103,7 +103,7 @@ def __init__(self, parent: QWidget | None = None) -> None: hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignCenter) hdr.setSectionResizeMode(hdr.ResizeMode.Stretch) - self.setHorizontalHeaderLabels(["X [mm]", "Y [mm]"]) + self.setHorizontalHeaderLabels(["X [µm]", "Y [µm]"]) self.selectionModel().selectionChanged.connect(self._on_selection_changed) def _on_selection_changed( @@ -190,11 +190,11 @@ def __init__( # WIDGETS ------------------------------------------------------------- # Well label - self._well_label = QLabel("Well A1") - font = self._well_label.font() + self.well_label = QLabel("Well A1") + font = self.well_label.font() font.setBold(True) font.setPixelSize(16) - self._well_label.setFont(font) + self.well_label.setFont(font) # Icon for calibration status self._calibration_icon = QLabel() @@ -217,7 +217,7 @@ def __init__( labels = QHBoxLayout() labels.setContentsMargins(0, 0, 0, 0) labels.addWidget(self._calibration_icon) - labels.addWidget(self._well_label, 1) + labels.addWidget(self.well_label, 1) mode_row = QHBoxLayout() mode_row.addWidget(QLabel("Method:")) @@ -248,6 +248,19 @@ def wellCenter(self) -> tuple[float, float] | None: """Return the center of the well, or None if not calibrated.""" return self._well_center + def setWellCenter(self, center: tuple[float, float] | None) -> None: + """Set the calibration icon and emit the calibrationChanged signal.""" + if self._well_center == center: + return + + self._well_center = center + if center is None: + icn = icon(NON_CALIBRATED_ICON, color=YELLOW) + else: + icn = icon(CALIBRATED_ICON, color=GREEN) + self._calibration_icon.setPixmap(icn.pixmap(ICON_SIZE)) + self.calibrationChanged.emit(center is not None) + def setCircularWell(self, circular: bool) -> None: """Update the calibration widget for circular or rectangular wells.""" self._calibration_mode_wdg.setCircularMode(circular) @@ -263,20 +276,7 @@ def _on_set_clicked(self) -> None: def _on_mode_changed(self, mode: Mode) -> None: """Update the rows in the calibration table.""" self._table.resetRowCount(mode.points) - self._set_well_center(None) - - def _set_well_center(self, center: tuple[float, float] | None) -> None: - """Set the calibration icon and emit the calibrationChanged signal.""" - if self._well_center == center: - return - - self._well_center = center - if center is None: - icn = icon(NON_CALIBRATED_ICON, color=YELLOW) - else: - icn = icon(CALIBRATED_ICON, color=GREEN) - self._calibration_icon.setPixmap(icn.pixmap(ICON_SIZE)) - self.calibrationChanged.emit(center is not None) + self.setWellCenter(None) def _validate_calibration(self) -> None: """Validate the calibration points added to the table.""" @@ -286,12 +286,12 @@ def _validate_calibration(self) -> None: # if the number of points is not yet satisfied, do nothing if len(points) < needed_points: - self._set_well_center(None) + self.setWellCenter(None) return # if the number of points is 1, well is already calibrated if needed_points == 1: - self._set_well_center(points[0]) + self.setWellCenter(points[0]) return # if the number of points is correct, try to calculate the calibration @@ -302,11 +302,11 @@ def _validate_calibration(self) -> None: else: x, y, width, height = find_rectangle_center(points) except Exception as e: # pragma: no cover - self._set_well_center(None) + self.setWellCenter(None) QMessageBox.critical( self, "Calibration error", f"Could not calculate the center of the well.\n\n{e}", ) else: - self._set_well_center((x, y)) + self.setWellCenter((x, y)) diff --git a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py index bccc66555..97554d31e 100644 --- a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py +++ b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py @@ -4,9 +4,10 @@ import useq from qtpy.QtCore import QRect, QRectF, QSize, Qt, Signal -from qtpy.QtGui import QFont, QMouseEvent, QPainter, QPen +from qtpy.QtGui import QColor, QFont, QMouseEvent, QPainter, QPen from qtpy.QtWidgets import ( QAbstractGraphicsShapeItem, + QAbstractItemView, QCheckBox, QComboBox, QGraphicsItem, @@ -38,11 +39,8 @@ def _sort_plate(item: str) -> tuple[int, int | str]: DATA_POSITION = 1 DATA_INDEX = 2 - -# in the WellPlateView, any item that merely posses a brush color of SELECTED_COLOR -# IS a selected object. -SELECTED_COLOR = Qt.GlobalColor.green -UNSELECTED_COLOR = Qt.GlobalColor.transparent +DATA_SELECTED = 3 +DATA_COLOR = 4 class WellPlateWidget(QWidget): @@ -195,10 +193,15 @@ class WellPlateView(ResizingGraphicsView): """QGraphicsView for displaying a well plate.""" selectionChanged = Signal() + SelectionMode = QAbstractItemView.SelectionMode def __init__(self, parent: QWidget | None = None) -> None: self._scene = QGraphicsScene() super().__init__(self._scene, parent) + self._selection_mode = QAbstractItemView.SelectionMode.MultiSelection + self._selected_color = Qt.GlobalColor.green + self._unselected_color = Qt.GlobalColor.transparent + self.setStyleSheet("background:grey; border-radius: 5px;") self.setRenderHints( QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform @@ -208,7 +211,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self.rubberBandChanged.connect(self._on_rubber_band_changed) # all the graphics items that outline wells - self._well_items: list[QAbstractGraphicsShapeItem] = [] + self._well_items: dict[tuple[int, int], QAbstractGraphicsShapeItem] = {} # all the graphics items that label wells self._well_labels: list[QGraphicsItem] = [] @@ -222,6 +225,20 @@ def __init__(self, parent: QWidget | None = None) -> None: # whether option/alt is pressed at the time of the mouse press self._is_removing = False + def setSelectedColor(self, color: Qt.GlobalColor) -> None: + """Set the color of the selected wells.""" + self._selected_color = color + + def selectedColor(self) -> Qt.GlobalColor: + """Return the color of the selected wells.""" + return self._selected_color + + def setSelectionMode(self, mode: QAbstractItemView.SelectionMode) -> None: + self._selection_mode = mode + + def selectionMode(self) -> QAbstractItemView.SelectionMode: + return self._selection_mode + def _on_rubber_band_changed(self, rect: QRect) -> None: """When the rubber band changes, select the items within the rectangle.""" if rect.isNull(): # pragma: no cover @@ -234,7 +251,7 @@ def _on_rubber_band_changed(self, rect: QRect) -> None: # loop through all wells and recolor them based on their selection state select = set() deselect = set() - for item in self._well_items: + for item in self._well_items.values(): if item in bounded_items: if self._is_removing: deselect.add(item) @@ -271,10 +288,14 @@ def mouseReleaseEvent(self, event: QMouseEvent | None) -> None: # toggle selection of that item for item in self.items(event.pos()): if item == self._pressed_item: - if self._pressed_item.brush().color() == SELECTED_COLOR: + if self._pressed_item.data(DATA_SELECTED) is True: self._deselect_items((self._pressed_item,)) else: - self._select_items((self._pressed_item,)) + if self._selection_mode == self.SelectionMode.SingleSelection: + # deselect all other items + self._deselect_items(self._selected_items) + if self._selection_mode != self.SelectionMode.NoSelection: + self._select_items((self._pressed_item,)) break self._pressed_item = None @@ -298,7 +319,7 @@ def setSelectedIndices(self, indices: Iterable[tuple[int, int]]) -> None: _indices = {tuple(idx) for idx in indices} select = set() deselect = set() - for item in self._well_items: + for item in self._well_items.values(): if item.data(DATA_INDEX) in _indices: select.add(item) else: @@ -314,7 +335,7 @@ def clearSelection(self) -> None: def clear(self) -> None: """Clear all the wells from the view.""" while self._well_items: - self._scene.removeItem(self._well_items.pop()) + self._scene.removeItem(self._well_items.popitem()[1]) while self._well_labels: self._scene.removeItem(self._well_labels.pop()) self.clearSelection() @@ -354,11 +375,12 @@ def drawPlate(self, plan: useq.WellPlate | useq.WellPlatePlan) -> None: rect = well_rect.translated(screen_x, screen_y) if item := add_item(rect, pen): item.setData(DATA_POSITION, pos) - item.setData(DATA_INDEX, tuple(idx.tolist())) + index = tuple(idx.tolist()) + item.setData(DATA_INDEX, index) if plan.rotation: item.setTransformOriginPoint(rect.center()) item.setRotation(-plan.rotation) - self._well_items.append(item) + self._well_items[index] = item # NOTE, we are *not* using the Qt selection model here due to # customizations that we want to make. So we don't use... @@ -385,13 +407,18 @@ def _resize_to_fit(self) -> None: def _select_items(self, items: Iterable[QAbstractGraphicsShapeItem]) -> None: for item in items: - item.setBrush(SELECTED_COLOR) + color = item.data(DATA_COLOR) or self._selected_color + item.setBrush(color) + item.setData(DATA_SELECTED, True) self._selected_items.update(items) self.selectionChanged.emit() def _deselect_items(self, items: Iterable[QAbstractGraphicsShapeItem]) -> None: for item in items: - item.setBrush(UNSELECTED_COLOR) + if item.data(DATA_SELECTED): + color = item.data(DATA_COLOR) or self._unselected_color + item.setBrush(color) + item.setData(DATA_SELECTED, False) self._selected_items.difference_update(items) self.selectionChanged.emit() @@ -401,3 +428,21 @@ def sizeHint(self) -> QSize: width = 600 height = int(width // aspect) return QSize(width, height) + + def setWellColor(self, row: int, col: int, color: Qt.GlobalColor | None) -> None: + """Set the color of the well at the given row and column. + + This overrides any selection color. If `color` is None, the well color is + determined by the selection state. + """ + if item := self._well_items.get((row, col)): + if color is not None: + color = QColor(color) + item.setData(DATA_COLOR, color) + if color is None: + color = ( + self._selected_color + if item.data(DATA_SELECTED) + else self._unselected_color + ) + item.setBrush(color) diff --git a/tests/test_hcs/test_well_calibration_widget.py b/tests/hcs/test_well_calibration_widget.py similarity index 100% rename from tests/test_hcs/test_well_calibration_widget.py rename to tests/hcs/test_well_calibration_widget.py diff --git a/tests/hcs/test_well_plate_calibration_widget.py b/tests/hcs/test_well_plate_calibration_widget.py new file mode 100644 index 000000000..d4490d96b --- /dev/null +++ b/tests/hcs/test_well_plate_calibration_widget.py @@ -0,0 +1,66 @@ +import useq +from pymmcore_plus import CMMCorePlus + +from pymmcore_widgets.hcs._plate_calibration_widget import PlateCalibrationWidget + + +def test_plate_calibration(global_mmcore: CMMCorePlus, qtbot) -> None: + w = PlateCalibrationWidget(mmcore=global_mmcore) + w.show() + qtbot.addWidget(w) + + w.setPlate("96-well") + assert w.platePlan().plate.rows == 8 + w.setPlate(useq.WellPlate.from_str("12-well")) + assert w.platePlan().plate.rows == 3 + w.setPlate(useq.WellPlatePlan(plate="24-well", a1_center_xy=(0, 0))) + assert w.platePlan().plate.rows == 4 + + ORIGIN = (10, 20) + # first well + w._plate_view.setSelectedIndices({(0, 0)}) + with qtbot.waitSignal(w.calibrationChanged): + w._current_calibration_widget().setWellCenter(ORIGIN) + assert not w._origin_spacing_rotation() + + # second well + w._plate_view.setSelectedIndices({(0, 1)}) + with qtbot.waitSignal(w.calibrationChanged): + w._current_calibration_widget().setWellCenter((ORIGIN[0] + 100, ORIGIN[1])) + assert not w._origin_spacing_rotation() + + # third well (will now be calibrated) + w._plate_view.setSelectedIndices({(1, 1)}) + with qtbot.waitSignal(w.calibrationChanged): + w._current_calibration_widget().setWellCenter( + (ORIGIN[0] + 100, ORIGIN[1] + 100) + ) + assert w._origin_spacing_rotation() is not None + assert w.platePlan().a1_center_xy == (ORIGIN) + + # clear third well, show that it becomes uncalibrated + with qtbot.waitSignal(w.calibrationChanged): + w._current_calibration_widget().setWellCenter(None) + assert w._origin_spacing_rotation() is None + + +def test_plate_calibration_colinear(global_mmcore: CMMCorePlus, qtbot): + w = PlateCalibrationWidget(mmcore=global_mmcore) + w.show() + qtbot.addWidget(w) + w.setPlate("24-well") + + spacing = 100 + for point in ((0, 0), (1, 1), (2, 2)): + w._plate_view.setSelectedIndices({point}) + with qtbot.waitSignal(w.calibrationChanged): + w._current_calibration_widget().setWellCenter( + (point[0] * spacing, point[1] * spacing) + ) + + assert "Ensure points are not collinear" in w._info.text() + w._plate_view.setSelectedIndices({(3, 2)}) + with qtbot.waitSignal(w.calibrationChanged): + w._current_calibration_widget().setWellCenter((3 * spacing, 2 * spacing)) + + assert "Ensure points are not collinear" not in w._info.text() From 112927f9ed3d2827473f2bf9d68fca6ab02796ad Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 7 Aug 2024 13:47:56 -0400 Subject: [PATCH 17/38] ci(pre-commit.ci): autoupdate (#357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(pre-commit.ci): autoupdate updates: - [github.com/crate-ci/typos: v1.22.9 → v1.23.6](https://github.com/crate-ci/typos/compare/v1.22.9...v1.23.6) - [github.com/astral-sh/ruff-pre-commit: v0.5.0 → v0.5.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.0...v0.5.6) - [github.com/pre-commit/mirrors-mypy: v1.10.1 → v1.11.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.10.1...v1.11.1) * style(pre-commit.ci): auto fixes [...] * fix lint --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Talley Lambert --- .pre-commit-config.yaml | 6 +++--- .../useq_widgets/points_plans/_points_plan_selector.py | 2 +- tests/useq_widgets/test_useq_points_plans.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2ac76657b..09d1f3969 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,12 +5,12 @@ ci: repos: - repo: https://github.com/crate-ci/typos - rev: v1.22.9 + rev: v1.23.6 hooks: - id: typos - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.0 + rev: v0.5.6 hooks: - id: ruff args: [--fix, --unsafe-fixes] @@ -22,7 +22,7 @@ repos: - id: validate-pyproject - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.1 + rev: v1.11.1 hooks: - id: mypy files: "^src/" diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py index c935b0815..e7433c248 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py @@ -119,7 +119,7 @@ def __init__(self, parent: QWidget | None = None) -> None: (self.random_radio_btn, self.random_points_wdg), (self.grid_radio_btn, self.grid_wdg), ): - wdg.setEnabled(btn.isChecked()) # type: ignore [attr-defined] + wdg.setEnabled(btn.isChecked()) grpbx = QGroupBox() # make a click on the groupbox act as a click on the button grpbx.mousePressEvent = lambda _, b=btn: b.setChecked(True) diff --git a/tests/useq_widgets/test_useq_points_plans.py b/tests/useq_widgets/test_useq_points_plans.py index 0ff69b042..65533cd2c 100644 --- a/tests/useq_widgets/test_useq_points_plans.py +++ b/tests/useq_widgets/test_useq_points_plans.py @@ -236,7 +236,7 @@ def test_points_plan_set_well_info(qtbot: QtBot) -> None: well = wdg._well_view._well_outline_item.sceneBoundingRect() bound = wdg._well_view._bounding_area_item.sceneBoundingRect() assert well.center() == bound.center() - offset = 20 # ofset automatically added when drawing + offset = 20 # offset automatically added when drawing # bounding rect should be 1/3 the size of the well rect in width assert well.width() - offset == (bound.width() - offset) * 3 # bounding rect should be 1/2 the size of the well rect in height From 3c63ba525ec4fe4a07076e978656bb055d129d70 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Fri, 23 Aug 2024 15:56:36 -0400 Subject: [PATCH 18/38] feat: plate navigator for HCS calibration testing (#356) * feat: wip PlateTestWidget * fix: wip * feat: click wip * feat: working stage movements + example * feat: preset movements * fix: _xy_label * fix: SELECTED_MOVE_TO_COLOR * fix: _MoveToItem size and color * fix: refactor * fix: adjust position if rotation * fix: invert rotated_y sign * fix: rename + fix _PresetPositionItem size * fix: update example with rotation * fix: remove print * test: wip * fix: use qtpy * test: update * test: update * fix: remove print * test: update * test: update * test: fix * fix: move test into hcs folder * fix: rename * fix: clear * fix: setPlate * fix: update layout * fix: margins * fix: spacing * feat: add well name to _xy_label * fix: spacing * unifying features * feat: calibration tab + _test_well_btn * fix: use _current_calibration_widget * fix: rename + random test points * fix: wip * changes * fix: update setPlate method * fix: refactor * fix: remove unused * fix: _add_preset_positions_items * fix: _get_random_edge_point * test: update tests * test: update tests * rename to value and setValue * add label * change click signal * minor --------- Co-authored-by: Talley Lambert --- examples/temp/plate_calibration_widget.py | 9 +- .../hcs/_plate_calibration_widget.py | 143 +++++++++++-- .../useq_widgets/_well_plate_widget.py | 111 +++++++++- .../hcs/test_well_plate_calibration_widget.py | 197 ++++++++++++++---- 4 files changed, 398 insertions(+), 62 deletions(-) diff --git a/examples/temp/plate_calibration_widget.py b/examples/temp/plate_calibration_widget.py index 23804c076..6cf88a568 100644 --- a/examples/temp/plate_calibration_widget.py +++ b/examples/temp/plate_calibration_widget.py @@ -1,3 +1,4 @@ +import useq from pymmcore_plus import CMMCorePlus from qtpy.QtWidgets import QApplication @@ -12,8 +13,14 @@ s = StageWidget("XY") s.show() +plan = useq.WellPlatePlan( + plate=useq.WellPlate.from_str("96-well"), + a1_center_xy=(1000, 1500), + rotation=0.3, +) + wdg = PlateCalibrationWidget(mmcore=mmc) -wdg.setPlate("96-well") +wdg.setValue(plan) wdg.show() app.exec() diff --git a/src/pymmcore_widgets/hcs/_plate_calibration_widget.py b/src/pymmcore_widgets/hcs/_plate_calibration_widget.py index 7af736770..f64c0133d 100644 --- a/src/pymmcore_widgets/hcs/_plate_calibration_widget.py +++ b/src/pymmcore_widgets/hcs/_plate_calibration_widget.py @@ -13,11 +13,13 @@ QPushButton, QStackedWidget, QStyle, + QTabWidget, QVBoxLayout, QWidget, ) from superqt.fonticon import icon +from pymmcore_widgets._util import SeparatorWidget from pymmcore_widgets.hcs._well_calibration_widget import ( CALIBRATED_ICON, GREEN, @@ -51,14 +53,28 @@ def __init__( # WIDGETS ------------------------------------------------------------ + self._tab_wdg = QTabWidget() + self._plate_view = WellPlateView() self._plate_view.setDragMode(WellPlateView.DragMode.NoDrag) self._plate_view.setSelectionMode(WellPlateView.SelectionMode.SingleSelection) self._plate_view.setSelectedColor(Qt.GlobalColor.yellow) - self._test_btn = QPushButton("Test Well", self) - self._test_btn.setEnabled(False) - self._test_btn.hide() + self._plate_test = WellPlateView() + lbl = QLabel("double-click on point to move stage", self._plate_test) + lbl.setGeometry(4, 0, 200, 20) + lbl.setStyleSheet("color: #CCC; font-size: 10px; background: transparent;") + self._plate_test.setDragMode(WellPlateView.DragMode.NoDrag) + self._plate_test.setSelectionMode(WellPlateView.SelectionMode.NoSelection) + self._plate_test.setDrawWellEdgeSpots(True) + self._plate_test.setDrawLabels(False) + + self._tab_wdg.addTab(self._plate_view, "Calibrate Plate") + self._tab_wdg.addTab(self._plate_test, "Test Calibration") + self._tab_wdg.setTabEnabled(1, False) + + self._test_well_btn = QPushButton("Test Well", self) + self._test_well_btn.setEnabled(False) # mapping of well index (r, c) to calibration widget # these are created on demand in _get_or_create_well_calibration_widget @@ -74,12 +90,12 @@ def __init__( right_layout = QVBoxLayout() right_layout.setContentsMargins(6, 0, 0, 0) right_layout.addWidget(self._calibration_widget_stack) - # right_layout.addWidget(SeparatorWidget()) - # right_layout.addWidget(self._test_btn) + right_layout.addWidget(SeparatorWidget()) + right_layout.addWidget(self._test_well_btn) right_layout.addStretch() top = QHBoxLayout() - top.addWidget(self._plate_view, 1) + top.addWidget(self._tab_wdg, 1) top.addLayout(right_layout) info_layout = QHBoxLayout() @@ -93,13 +109,32 @@ def __init__( # CONNECTIONS --------------------------------------------------------- self._plate_view.selectionChanged.connect(self._on_plate_selection_changed) + self._tab_wdg.currentChanged.connect(self._on_tab_changed) + self._plate_test.positionDoubleClicked.connect(self._move_to_xy_position) + self._test_well_btn.clicked.connect(self._move_to_test_position) + + # ---------------------------PUBLIC API----------------------------------- + + def setValue(self, plate: str | useq.WellPlate | useq.WellPlatePlan) -> None: + """Set the plate to be calibrated. + + Parameters + ---------- + plate : str | useq.WellPlate | useq.WellPlatePlan + The well plate to calibrate. If a string, it is assumed to be the name of a + well plate (e.g. "96-well"). If a WellPlate instance, it is used directly. + If a WellPlatePlan instance, the plate is set to the plan's plate and the + widget state is set to calibrated. + """ + calibrated: bool = False + plan: useq.WellPlatePlan | None = None - def setPlate(self, plate: str | useq.WellPlate | useq.WellPlatePlan) -> None: - """Set the plate to be calibrated.""" if isinstance(plate, str): plate = useq.WellPlate.from_str(plate) elif isinstance(plate, useq.WellPlatePlan): + plan = plate plate = plate.plate + calibrated = True self._current_plate = plate self._plate_view.drawPlate(plate) @@ -110,22 +145,91 @@ def setPlate(self, plate: str | useq.WellPlate | useq.WellPlatePlan) -> None: self._calibration_widget_stack.removeWidget(wdg) wdg.deleteLater() - # Select A1 well - self._plate_view.setSelectedIndices([(0, 0)]) + self._plate_view.setSelectedIndices([(0, 0)]) # select A1 - def platePlan(self) -> useq.WellPlatePlan: + if calibrated and plan is not None: + self._plate_test.drawPlate(plan) + self._update_info((plan.a1_center_xy, plate.well_spacing, plan.rotation)) + else: + self._plate_test.clear() + self._update_info(None) + + self._tab_wdg.setTabEnabled(1, calibrated) + self.calibrationChanged.emit(calibrated) + + def value(self) -> useq.WellPlatePlan | None: """Return the plate plan with calibration information.""" a1_center_xy = (0.0, 0.0) rotation: float = 0.0 if (osr := self._origin_spacing_rotation()) is not None: a1_center_xy, (unit_x, unit_y), rotation = osr + if self._current_plate is None: # pragma: no cover + return None return useq.WellPlatePlan( plate=self._current_plate, a1_center_xy=a1_center_xy, rotation=rotation, ) - # ----------------------------------------------- + # ---------------------------PRIVATE API---------------------------------- + + def _move_to_xy_position(self, pos: useq.Position) -> None: + """Move the stage to the selected well position.""" + self._mmc.waitForSystem() + x, y = pos.x, pos.y + if x is None or y is None: # pragma: no cover + return + self._mmc.setXYPosition(x, y) + + def _move_to_test_position(self) -> None: + """Move the stage to the edge of the selected well.""" + if well_wdg := self._current_calibration_widget(): + plate = self._current_plate + if plate is None: # pragma: no cover + return + if well_center := well_wdg.wellCenter(): + rnd_x, rnd_y = self._get_random_edge_point(plate, well_center) + self._move_to_xy_position(useq.Position(x=rnd_x, y=rnd_y)) + + def _get_random_edge_point( + self, plate: useq.WellPlate, well_center: tuple[float, float] + ) -> tuple[float, float]: + """Return a random point along the edge of the well. + + It returns a random point along the circumference of the well if the well is + circular, otherwise it returns a random point along the edge of the well. + """ + x, y = well_center + width = plate.well_size[0] * 1000 # convert to µm + height = plate.well_size[1] * 1000 # convert to µm + + self._mmc.waitForSystem() + curr_x, curr_y = self._mmc.getXYPosition() + + while True: + # if circular, get a random point along the circumference of the well + if plate.circular_wells: + angle = np.random.uniform(0, 2 * np.pi) + rnd_x = x + width / 2 * np.cos(angle) + rnd_y = y + height / 2 * np.sin(angle) + # otherwise get the vertices of the squared/rectangular well + else: + edges = [ + (x - width / 2, y - height / 2), # top left + (x + width / 2, y - height / 2), # top right + (x + width / 2, y + height / 2), # bottom right + (x - width / 2, y + height / 2), # bottom left + ] + rnd_x, rnd_y = edges[np.random.randint(0, 4)] + # make sure the random point is not the current point + if (round(curr_x), round(curr_y)) != (round(rnd_x), round(rnd_y)): + return rnd_x, rnd_y + + def _on_tab_changed(self, idx: int) -> None: + """Hide or show the well calibration widget based on the selected tab.""" + if well_wdg := self._current_calibration_widget(): + well_wdg.setEnabled(idx == 0) # enable when calibrate tab is selected + self._test_well_btn.setEnabled(idx == 0) def _origin_spacing_rotation( self, @@ -201,11 +305,11 @@ def _on_plate_selection_changed(self) -> None: self._calibration_widget_stack.setCurrentWidget(well_calib_wdg) # enable/disable test button - self._test_btn.setEnabled(idx in self._calibrated_wells) + self._test_well_btn.setEnabled(idx in self._calibrated_wells) def _on_well_calibration_changed(self, calibrated: bool) -> None: """The current well calibration state has been changed.""" - self._test_btn.setEnabled(calibrated) + self._test_well_btn.setEnabled(calibrated) if idx := self._selected_well_index(): # update the color of the well in the plate view accordingly if calibrated and (well_calib_wdg := self._current_calibration_widget()): @@ -219,10 +323,17 @@ def _on_well_calibration_changed(self, calibrated: bool) -> None: osr = self._origin_spacing_rotation() fully_calibrated = osr is not None self._update_info(osr) + + if fully_calibrated and self._current_plate: + self._plate_test.drawPlate(self._current_plate) + else: + self._plate_test.clear() + + self._tab_wdg.setTabEnabled(1, fully_calibrated) self.calibrationChanged.emit(fully_calibrated) def _update_info( - self, osr: tuple[tuple[float, float], tuple[float, float], float] | None + self, osr: tuple[tuple[float, float], tuple[float, float], float | None] | None ) -> None: style = self.style() if osr is not None: @@ -242,7 +353,7 @@ def _update_info( txt += "
" txt += f"\nA1 Center [mm]: ({origin[0]/1000:.2f}, {origin[1]/1000:.2f}), " txt += f"Well Spacing [mm]: ({spacing[0]:.2f}, {spacing[1]:.2f}), " - txt += f"Rotation: {rotation}°" + txt += f"Rotation: {rotation if rotation is not None else 0}°" elif len(self._calibrated_wells) < self._min_wells_required: txt = f"Please calibrate at least {self._min_wells_required} wells." ico = style.standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation) diff --git a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py index 97554d31e..4ab5d44df 100644 --- a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py +++ b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Iterable, Mapping +import numpy as np import useq from qtpy.QtCore import QRect, QRectF, QSize, Qt, Signal from qtpy.QtGui import QColor, QFont, QMouseEvent, QPainter, QPen @@ -10,8 +11,10 @@ QAbstractItemView, QCheckBox, QComboBox, + QGraphicsEllipseItem, QGraphicsItem, QGraphicsScene, + QGraphicsSceneHoverEvent, QHBoxLayout, QLabel, QPushButton, @@ -189,10 +192,32 @@ def _on_show_rotation_toggled(self, checked: bool) -> None: self._view.drawPlate(val) +class HoverEllipse(QGraphicsEllipseItem): + def __init__(self, rect: QRectF, parent: QGraphicsItem | None = None): + super().__init__(rect, parent) + self.setAcceptHoverEvents(True) + self._selected_color = Qt.GlobalColor.green + self._unselected_color = Qt.GlobalColor.black + self.setBrush(self._unselected_color) + + def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent | None) -> None: + """Update color and position when hovering over the well.""" + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setBrush(self._selected_color) + super().hoverEnterEvent(event) + + def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent | None) -> None: + """Reset color and position when leaving the well.""" + self.setCursor(Qt.CursorShape.ArrowCursor) + self.setBrush(self._unselected_color) + super().hoverLeaveEvent(event) + + class WellPlateView(ResizingGraphicsView): """QGraphicsView for displaying a well plate.""" selectionChanged = Signal() + positionDoubleClicked = Signal(useq.Position) SelectionMode = QAbstractItemView.SelectionMode def __init__(self, parent: QWidget | None = None) -> None: @@ -201,6 +226,8 @@ def __init__(self, parent: QWidget | None = None) -> None: self._selection_mode = QAbstractItemView.SelectionMode.MultiSelection self._selected_color = Qt.GlobalColor.green self._unselected_color = Qt.GlobalColor.transparent + self._draw_labels: bool = True + self._draw_well_edge_spots: bool = False self.setStyleSheet("background:grey; border-radius: 5px;") self.setRenderHints( @@ -214,6 +241,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._well_items: dict[tuple[int, int], QAbstractGraphicsShapeItem] = {} # all the graphics items that label wells self._well_labels: list[QGraphicsItem] = [] + self._well_edge_spots: list[QGraphicsItem] = [] # we manually manage the selection state of items self._selected_items: set[QAbstractGraphicsShapeItem] = set() @@ -239,6 +267,14 @@ def setSelectionMode(self, mode: QAbstractItemView.SelectionMode) -> None: def selectionMode(self) -> QAbstractItemView.SelectionMode: return self._selection_mode + def setDrawLabels(self, draw: bool) -> None: + """Set whether to draw the well labels.""" + self._draw_labels = draw + + def setDrawWellEdgeSpots(self, draw: bool) -> None: + """Set whether to draw the well edge spots.""" + self._draw_well_edge_spots = draw + def _on_rubber_band_changed(self, rect: QRect) -> None: """When the rubber band changes, select the items within the rectangle.""" if rect.isNull(): # pragma: no cover @@ -303,6 +339,15 @@ def mouseReleaseEvent(self, event: QMouseEvent | None) -> None: self._selection_on_press.clear() super().mouseReleaseEvent(event) + def mouseDoubleClickEvent(self, event: QMouseEvent | None) -> None: + """Emit stage position when a position-storing item is double-clicked.""" + if event is not None: + if pos := next( + (item.data(DATA_POSITION) for item in self.items(event.pos())), None + ): + self.positionDoubleClicked.emit(pos) + super().mouseDoubleClickEvent(event) + def selectedIndices(self) -> tuple[tuple[int, int], ...]: """Return the indices of the selected wells.""" return tuple(sorted(item.data(DATA_INDEX) for item in self._selected_items)) @@ -338,6 +383,8 @@ def clear(self) -> None: self._scene.removeItem(self._well_items.popitem()[1]) while self._well_labels: self._scene.removeItem(self._well_labels.pop()) + while self._well_edge_spots: + self._scene.removeItem(self._well_edge_spots.pop()) self.clearSelection() def drawPlate(self, plan: useq.WellPlate | useq.WellPlatePlan) -> None: @@ -387,20 +434,68 @@ def drawPlate(self, plan: useq.WellPlate | useq.WellPlatePlan) -> None: # item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) # add text - if text_item := self._scene.addText(pos.name): - text_item.setFont(font) - br = text_item.boundingRect() - text_item.setPos( - screen_x - br.width() // 2, - screen_y - br.height() // 2, - ) - self._well_labels.append(text_item) + if self._draw_labels: + if text_item := self._scene.addText(pos.name): + text_item.setFont(font) + br = text_item.boundingRect() + text_item.setPos( + screen_x - br.width() // 2, screen_y - br.height() // 2 + ) + self._well_labels.append(text_item) + + if self._draw_well_edge_spots: + self._add_preset_positions_items(rect, pos, plan) if plan.selected_wells: self.setSelectedIndices(plan.selected_well_indices) self._resize_to_fit() + def _add_preset_positions_items( + self, + rect: QRectF, + pos: useq.Position, + plan: useq.WellPlatePlan, + ) -> None: + plate = plan.plate + well_width = plate.well_size[0] * 1000 + well_height = plate.well_size[1] * 1000 + + # calculate radius for the _PresetPositionItem based on well spacing and size + half_sx = plate.well_spacing[0] * 1000 / 2 + half_sy = plate.well_spacing[1] * 1000 / 2 + width, height = half_sx - well_width / 2, half_sy - well_height / 2 + width = min(width, height) + + # central point + cx, cy = rect.center().x(), rect.center().y() + positions = [ + [cx, cy], + [cx - well_width / 2, cy], # center # left + [cx + well_width / 2, cy], # right + [cx, cy - well_height / 2], # top + [cx, cy + well_height / 2], # bottom + ] + + if plan.rotation: + rad = -np.deg2rad(plan.rotation) + cos_rad = np.cos(rad) + sin_rad = np.sin(rad) + for p in positions: + dx, dy = p[0] - cx, p[1] - cy + rotated_x = cx + dx * cos_rad - dy * sin_rad + rotated_y = cy + dx * sin_rad + dy * cos_rad + p[0], p[1] = rotated_x, rotated_y + + for x, y in positions: + edge_rect = QRectF(x - width / 2, y - width / 2, width, width) + new_pos = useq.Position(x=x, y=y, name=pos.name) + item = HoverEllipse(edge_rect) + item.setData(DATA_POSITION, new_pos) + item.setZValue(1) # make sure it's on top + self._scene.addItem(item) + self._well_edge_spots.append(item) + def _resize_to_fit(self) -> None: self.setSceneRect(self._scene.itemsBoundingRect()) self.resizeEvent(None) diff --git a/tests/hcs/test_well_plate_calibration_widget.py b/tests/hcs/test_well_plate_calibration_widget.py index d4490d96b..00967c8a5 100644 --- a/tests/hcs/test_well_plate_calibration_widget.py +++ b/tests/hcs/test_well_plate_calibration_widget.py @@ -2,65 +2,188 @@ from pymmcore_plus import CMMCorePlus from pymmcore_widgets.hcs._plate_calibration_widget import PlateCalibrationWidget +from pymmcore_widgets.useq_widgets._well_plate_widget import DATA_POSITION def test_plate_calibration(global_mmcore: CMMCorePlus, qtbot) -> None: - w = PlateCalibrationWidget(mmcore=global_mmcore) - w.show() - qtbot.addWidget(w) + wdg = PlateCalibrationWidget(mmcore=global_mmcore) + wdg.show() + qtbot.addWidget(wdg) - w.setPlate("96-well") - assert w.platePlan().plate.rows == 8 - w.setPlate(useq.WellPlate.from_str("12-well")) - assert w.platePlan().plate.rows == 3 - w.setPlate(useq.WellPlatePlan(plate="24-well", a1_center_xy=(0, 0))) - assert w.platePlan().plate.rows == 4 + with qtbot.waitSignal(wdg.calibrationChanged) as sig: + wdg.setValue( + useq.WellPlatePlan(plate="24-well", a1_center_xy=(0, 0), rotation=2) + ) + assert sig.args == [True] + assert wdg.value().plate.rows == 4 + assert wdg._tab_wdg.isTabEnabled(1) + + with qtbot.waitSignal(wdg.calibrationChanged) as sig: + wdg.setValue("96-well") + assert sig.args == [False] + assert wdg.value() + assert wdg.value().plate.rows == 8 + assert not wdg._tab_wdg.isTabEnabled(1) + + with qtbot.waitSignal(wdg.calibrationChanged) as sig: + wdg.setValue(useq.WellPlate.from_str("12-well")) + assert sig.args == [False] + assert wdg.value().plate.rows == 3 + assert not wdg._tab_wdg.isTabEnabled(1) ORIGIN = (10, 20) # first well - w._plate_view.setSelectedIndices({(0, 0)}) - with qtbot.waitSignal(w.calibrationChanged): - w._current_calibration_widget().setWellCenter(ORIGIN) - assert not w._origin_spacing_rotation() + wdg._plate_view.setSelectedIndices({(0, 0)}) + with qtbot.waitSignal(wdg.calibrationChanged) as sig: + wdg._current_calibration_widget().setWellCenter(ORIGIN) + assert sig.args == [False] + assert not wdg._origin_spacing_rotation() + assert not wdg._tab_wdg.isTabEnabled(1) # second well - w._plate_view.setSelectedIndices({(0, 1)}) - with qtbot.waitSignal(w.calibrationChanged): - w._current_calibration_widget().setWellCenter((ORIGIN[0] + 100, ORIGIN[1])) - assert not w._origin_spacing_rotation() + wdg._plate_view.setSelectedIndices({(0, 1)}) + with qtbot.waitSignal(wdg.calibrationChanged) as sig: + wdg._current_calibration_widget().setWellCenter((ORIGIN[0] + 100, ORIGIN[1])) + assert sig.args == [False] + assert not wdg._origin_spacing_rotation() + assert not wdg._tab_wdg.isTabEnabled(1) # third well (will now be calibrated) - w._plate_view.setSelectedIndices({(1, 1)}) - with qtbot.waitSignal(w.calibrationChanged): - w._current_calibration_widget().setWellCenter( + wdg._plate_view.setSelectedIndices({(1, 1)}) + with qtbot.waitSignal(wdg.calibrationChanged) as sig: + wdg._current_calibration_widget().setWellCenter( (ORIGIN[0] + 100, ORIGIN[1] + 100) ) - assert w._origin_spacing_rotation() is not None - assert w.platePlan().a1_center_xy == (ORIGIN) + assert sig.args == [True] + assert wdg._tab_wdg.isTabEnabled(1) + assert wdg._origin_spacing_rotation() is not None + assert wdg.value().a1_center_xy == (ORIGIN) # clear third well, show that it becomes uncalibrated - with qtbot.waitSignal(w.calibrationChanged): - w._current_calibration_widget().setWellCenter(None) - assert w._origin_spacing_rotation() is None + with qtbot.waitSignal(wdg.calibrationChanged) as sig: + wdg._current_calibration_widget().setWellCenter(None) + assert sig.args == [False] + assert not wdg._tab_wdg.isTabEnabled(1) + assert wdg._origin_spacing_rotation() is None def test_plate_calibration_colinear(global_mmcore: CMMCorePlus, qtbot): - w = PlateCalibrationWidget(mmcore=global_mmcore) - w.show() - qtbot.addWidget(w) - w.setPlate("24-well") + wdg = PlateCalibrationWidget(mmcore=global_mmcore) + wdg.show() + qtbot.addWidget(wdg) + wdg.setValue("24-well") spacing = 100 for point in ((0, 0), (1, 1), (2, 2)): - w._plate_view.setSelectedIndices({point}) - with qtbot.waitSignal(w.calibrationChanged): - w._current_calibration_widget().setWellCenter( + wdg._plate_view.setSelectedIndices({point}) + with qtbot.waitSignal(wdg.calibrationChanged): + wdg._current_calibration_widget().setWellCenter( (point[0] * spacing, point[1] * spacing) ) - assert "Ensure points are not collinear" in w._info.text() - w._plate_view.setSelectedIndices({(3, 2)}) - with qtbot.waitSignal(w.calibrationChanged): - w._current_calibration_widget().setWellCenter((3 * spacing, 2 * spacing)) + assert "Ensure points are not collinear" in wdg._info.text() + wdg._plate_view.setSelectedIndices({(3, 2)}) + with qtbot.waitSignal(wdg.calibrationChanged): + wdg._current_calibration_widget().setWellCenter((3 * spacing, 2 * spacing)) + + assert "Ensure points are not collinear" not in wdg._info.text() + + +def test_plate_calibration_items(global_mmcore: CMMCorePlus, qtbot) -> None: + wdg = PlateCalibrationWidget(mmcore=global_mmcore) + wdg.show() + qtbot.addWidget(wdg) + + scene = wdg._plate_view._scene + scene_test = wdg._plate_test._scene + + # set the plan so that the plate is calibrated + wdg.setValue(useq.WellPlatePlan(plate="96-well", a1_center_xy=(0, 0))) + assert scene.items() + # we should have 96 QGraphicsEllipseItem wells, each with a QGraphicsTextItem + assert len(scene.items()) == 96 * 2 + + assert scene_test.items() + # we should have 96 QGraphicsEllipseItem wells, each with 5 HoverWellItem + assert len(scene_test.items()) == 96 + (96 * 5) + + # we should not have any items in the test scene since not yet calibrated + wdg.setValue("24-well") + assert not scene_test.items() + + +def test_plate_calibration_well_test(global_mmcore: CMMCorePlus, qtbot) -> None: + import math + + wdg = PlateCalibrationWidget(mmcore=global_mmcore) + wdg.show() + qtbot.addWidget(wdg) + + # circular plate + wdg.setValue("96-well") + well_wdg = wdg._current_calibration_widget() + well_wdg.setWellCenter((100, 100)) + + assert global_mmcore.getXPosition() == 0 + assert global_mmcore.getYPosition() == 0 + + global_mmcore.waitForSystem() + wdg._move_to_test_position() + + global_mmcore.waitForSystem() + x, y = global_mmcore.getXYPosition() + cx, cy = well_wdg.wellCenter() + w, h = wdg.value().plate.well_size + r = w * 1000 / 2 + distance_squared = (x - cx) ** 2 + (y - cy) ** 2 + # assert that the current position is on the circumference of the well + assert math.isclose(distance_squared, r**2, abs_tol=100) + + # rectangular plate + wdg.setValue("384-well") + well_wdg = wdg._current_calibration_widget() + well_wdg.setWellCenter((100, 100)) + + global_mmcore.waitForSystem() + wdg._move_to_test_position() + + global_mmcore.waitForSystem() + x, y = global_mmcore.getXYPosition() + cx, cy = well_wdg.wellCenter() + w, h = wdg.value().plate.well_size + w, h = w * 1000, h * 1000 + + vertices = [ + (round(cx - w / 2), round(cy - h / 2)), # top left + (round(cx + w / 2), round(cy - h / 2)), # top right + (round(cx + w / 2), round(cy + h / 2)), # bottom right + (round(cx - w / 2), round(cy + h / 2)), # bottom left + ] + # assert that the current position is one of the vertices + assert (round(x), round(y)) in vertices + + +def test_plate_calibration_test_positions(global_mmcore: CMMCorePlus, qtbot) -> None: + wdg = PlateCalibrationWidget(mmcore=global_mmcore) + wdg.show() + qtbot.addWidget(wdg) + + wdg.setValue(useq.WellPlatePlan(plate="96-well", a1_center_xy=(0, 0))) + + scene = wdg._plate_test._scene + assert scene.items() + + hover_items = list(reversed(scene.items()))[96:101] + expected_data = [ + (0, 0, "A1"), + (-3200, 0, "A1"), + (3200, 0, "A1"), + (0, -3200, "A1"), + (0, 3200, "A1"), + ] + data = [] + for hover_item in hover_items: + hover_item_data = hover_item.data(DATA_POSITION) + data.append((hover_item_data.x, hover_item_data.y, hover_item_data.name)) - assert "Ensure points are not collinear" not in w._info.text() + assert data == expected_data From 7735cb877d0f4ae2dff083ae442cf0b1e26f6aff Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Wed, 11 Sep 2024 17:15:36 -0500 Subject: [PATCH 19/38] feat: reload prior config file on HCW rejection (#359) --- src/pymmcore_widgets/hcwizard/config_wizard.py | 7 +++++++ tests/test_config_wizard.py | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/pymmcore_widgets/hcwizard/config_wizard.py b/src/pymmcore_widgets/hcwizard/config_wizard.py index a4c5391ea..79589f679 100644 --- a/src/pymmcore_widgets/hcwizard/config_wizard.py +++ b/src/pymmcore_widgets/hcwizard/config_wizard.py @@ -128,6 +128,13 @@ def accept(self) -> None: self._model.save(dest_path) super().accept() + def reject(self) -> None: + """Reject the wizard and reload the prior configuration.""" + super().reject() + last_config_file = self._core.systemConfigurationFile() + if last_config_file is not None: + self._core.loadSystemConfiguration(last_config_file) + def _update_step(self, current_index: int) -> None: """Change text on the left when the page changes.""" for i, label in enumerate(self.step_labels): diff --git a/tests/test_config_wizard.py b/tests/test_config_wizard.py index 5e39f14e3..889d25305 100644 --- a/tests/test_config_wizard.py +++ b/tests/test_config_wizard.py @@ -76,6 +76,21 @@ def test_config_wizard(global_mmcore: CMMCorePlus, qtbot, tmp_path: Path): wiz.closeEvent(QCloseEvent()) +def test_config_wizard_rejection(global_mmcore: CMMCorePlus, qtbot, tmp_path: Path): + global_mmcore.loadSystemConfiguration(str(TEST_CONFIG)) + st1 = global_mmcore.getSystemState() + + wiz = ConfigWizard(str(TEST_CONFIG), global_mmcore) + qtbot.addWidget(wiz) + wiz.show() + wiz.reject() + + # Assert system state prior to wizard execution still present + st2 = global_mmcore.getSystemState() + + assert st1 == st2 + + def test_config_wizard_devices( global_mmcore: CMMCorePlus, qtbot: QtBot, tmp_path: Path, qapp ): From fb1e1839be894e27e427404a17d9a42af7889862 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:29:47 -0400 Subject: [PATCH 20/38] ci(pre-commit.ci): autoupdate (#358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/crate-ci/typos: v1.23.6 → typos-dict-v0.11.27](https://github.com/crate-ci/typos/compare/v1.23.6...typos-dict-v0.11.27) - [github.com/astral-sh/ruff-pre-commit: v0.5.6 → v0.6.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.6...v0.6.3) - [github.com/abravalheri/validate-pyproject: v0.18 → v0.19](https://github.com/abravalheri/validate-pyproject/compare/v0.18...v0.19) - [github.com/pre-commit/mirrors-mypy: v1.11.1 → v1.11.2](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.1...v1.11.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 09d1f3969..4147be169 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,24 +5,24 @@ ci: repos: - repo: https://github.com/crate-ci/typos - rev: v1.23.6 + rev: typos-dict-v0.11.27 hooks: - id: typos - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.6 + rev: v0.6.3 hooks: - id: ruff args: [--fix, --unsafe-fixes] - id: ruff-format - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.18 + rev: v0.19 hooks: - id: validate-pyproject - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.1 + rev: v1.11.2 hooks: - id: mypy files: "^src/" From 3dab0c4c22cc68916f51c2d9deccd0567d686c7a Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Wed, 18 Sep 2024 15:14:28 -0400 Subject: [PATCH 21/38] feat: add new calibration wdg (#360) * PlateDatabaseWidget * delete some stuff * add example * running * wip * lots of cleanup * style(pre-commit.ci): auto fixes [...] * more tweaks * feat: wip PlateTestWidget * fix: wip * feat: click wip * feat: working stage movements + example * feat: preset movements * fix: _xy_label * fix: SELECTED_MOVE_TO_COLOR * fix: _MoveToItem size and color * fix: refactor * fix: adjust position if rotation * fix: invert rotated_y sign * fix: rename + fix _PresetPositionItem size * fix: update example with rotation * fix: remove print * test: wip * fix: use qtpy * test: update * test: update * fix: remove print * test: update * test: update * test: fix * fix: move test into hcs folder * fix: rename * fix: clear * fix: setPlate * fix: update layout * fix: margins * fix: spacing * feat: add well name to _xy_label * fix: spacing * unifying features * feat: calibration tab + _test_well_btn * fix: use _current_calibration_widget * fix: rename + random test points * fix: wip * changes * fix: update setPlate method * fix: refactor * fix: remove unused * fix: _add_preset_positions_items * fix: _get_random_edge_point * test: update tests * test: update tests * style(pre-commit.ci): auto fixes [...] * feat: add new calibration wdg * fix: rename methods * add _calibrated flag * rename to value and setValue * fix _on_plate_changed] * todo * add label * change click signal * minor * fix: add QGroupBox * fix: sizes + rotation * fix: accept * fix: HoverEllipse tooltip * fix: accept * fix: example * fix: example * fix: deselect wells with no explicit selection * undo last change * more changes in behavior * some docs * remove WellPlateWidgetNoRotation * supress runtime error * updates * fix: clear selection when plate changes * fix: emit signal when selecting a different plate * fix: update fov size when calling setValue * fix: test_well_btn no focus policy * fix: fix bug when setting selected_wells * test: fix * fix: systemConfigurationLoaded * test: add failing test * tests: fix tests * pragma --------- Co-authored-by: Talley Lambert Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- examples/hcs_wizard.py | 32 +++ src/pymmcore_widgets/__init__.py | 2 + src/pymmcore_widgets/hcs/__init__.py | 6 +- src/pymmcore_widgets/hcs/_hcs_wizard.py | 228 ++++++++++++++++++ .../hcs/_plate_calibration_widget.py | 73 +++--- .../useq_widgets/_well_plate_widget.py | 96 +++++--- .../points_plans/_grid_row_column_widget.py | 2 + .../points_plans/_points_plan_selector.py | 7 +- .../points_plans/_points_plan_widget.py | 9 +- .../points_plans/_random_points_widget.py | 1 + tests/hcs/test_hcs_wizard.py | 34 +++ .../hcs/test_well_plate_calibration_widget.py | 8 + tests/useq_widgets/test_useq_points_plans.py | 8 +- 13 files changed, 428 insertions(+), 78 deletions(-) create mode 100644 examples/hcs_wizard.py create mode 100644 src/pymmcore_widgets/hcs/_hcs_wizard.py create mode 100644 tests/hcs/test_hcs_wizard.py diff --git a/examples/hcs_wizard.py b/examples/hcs_wizard.py new file mode 100644 index 000000000..5427828f2 --- /dev/null +++ b/examples/hcs_wizard.py @@ -0,0 +1,32 @@ +from contextlib import suppress + +import useq +from pymmcore_plus import CMMCorePlus +from qtpy.QtWidgets import QApplication + +from pymmcore_widgets import StageWidget + +with suppress(ImportError): + from rich import print + +from pymmcore_widgets.hcs import HCSWizard + +app = QApplication([]) +mmc = CMMCorePlus.instance() +mmc.loadSystemConfiguration() +w = HCSWizard() +w.show() +w.accepted.connect(lambda: print(w.value())) +s = StageWidget("XY", mmcore=mmc) +s.show() + + +plan = useq.WellPlatePlan( + plate=useq.WellPlate.from_str("96-well"), + a1_center_xy=(1000, 1500), + rotation=0.3, + selected_wells=slice(0, 8, 2), +) +w.setValue(plan) + +app.exec() diff --git a/src/pymmcore_widgets/__init__.py b/src/pymmcore_widgets/__init__.py index 5b008d709..8a468a0f2 100644 --- a/src/pymmcore_widgets/__init__.py +++ b/src/pymmcore_widgets/__init__.py @@ -28,6 +28,7 @@ from ._shutter_widget import ShuttersWidget from ._snap_button_widget import SnapButton from ._stage_widget import StageWidget +from .hcs import HCSWizard from .hcwizard import ConfigWizard from .mda import MDAWidget from .useq_widgets import ( @@ -78,6 +79,7 @@ def __getattr__(name: str) -> object: "ExposureWidget", "GridPlanWidget", "GroupPresetTableWidget", + "HCSWizard", "ImagePreview", "InstallWidget", "LiveButton", diff --git a/src/pymmcore_widgets/hcs/__init__.py b/src/pymmcore_widgets/hcs/__init__.py index 0ebac13cb..6609c4a2c 100644 --- a/src/pymmcore_widgets/hcs/__init__.py +++ b/src/pymmcore_widgets/hcs/__init__.py @@ -1 +1,5 @@ -"""Calibration widget.""" +"""HCS Wizard.""" + +from ._hcs_wizard import HCSWizard + +__all__ = ["HCSWizard"] diff --git a/src/pymmcore_widgets/hcs/_hcs_wizard.py b/src/pymmcore_widgets/hcs/_hcs_wizard.py new file mode 100644 index 000000000..4fca96bb1 --- /dev/null +++ b/src/pymmcore_widgets/hcs/_hcs_wizard.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import warnings +from contextlib import suppress +from pathlib import Path + +import useq +from pymmcore_plus import CMMCorePlus +from qtpy.QtCore import QSize +from qtpy.QtWidgets import QFileDialog, QVBoxLayout, QWidget, QWizard, QWizardPage +from useq import WellPlatePlan + +from pymmcore_widgets.useq_widgets import PointsPlanWidget, WellPlateWidget + +from ._plate_calibration_widget import PlateCalibrationWidget + + +class HCSWizard(QWizard): + """A wizard to setup an High Content Screening (HCS) experiment. + + This widget can be used to select a plate, calibrate it, and then select the number + of images (and their arrangement) to acquire per well. The output is a + [useq.WellPlatePlan][] object, which can be retrieved with the `value()` method. + + Parameters + ---------- + parent : QWidget | None + The parent widget. By default, None. + mmcore : CMMCorePlus | None + The CMMCorePlus instance. By default, None. + """ + + def __init__( + self, parent: QWidget | None = None, *, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__(parent) + self._mmc = mmcore or CMMCorePlus.instance() + self._calibrated: bool = False + + self.setWizardStyle(QWizard.WizardStyle.ModernStyle) + self.setWindowTitle("HCS Wizard") + + # WIZARD PAGES ---------------------- + + self.plate_page = _PlatePage(self) + self.calibration_page = _PlateCalibrationPage(self._mmc, self) + self.points_plan_page = _PointsPlanPage(self._mmc, self) + + self.addPage(self.plate_page) + self.addPage(self.calibration_page) + self.addPage(self.points_plan_page) + + # SAVE/LOAD BUTTONS ---------------------- + + # add custom button to save + self.setOption(QWizard.WizardOption.HaveCustomButton1, True) + if save_btn := self.button(QWizard.WizardButton.CustomButton1): + save_btn.setText("Save") + save_btn.clicked.connect(self.save) + save_btn.setEnabled(False) + # add custom button to load + self.setOption(QWizard.WizardOption.HaveCustomButton2, True) + if load_btn := self.button(QWizard.WizardButton.CustomButton2): + load_btn.setText("Load") + load_btn.clicked.connect(self.load) + + # CONNECTIONS --------------------------- + + self.plate_page.widget.valueChanged.connect(self._on_plate_changed) + self._on_plate_changed(self.plate_page.widget.value()) + self.calibration_page.widget.calibrationChanged.connect( + self._on_calibration_changed + ) + + def sizeHint(self) -> QSize: + return QSize(880, 690) + + def value(self) -> useq.WellPlatePlan | None: + """Return the current well plate plan, or None if the plan is uncalibrated.""" + calib_plan = self.calibration_page.widget.value() + if not self._calibrated or not calib_plan: # pragma: no cover + return None + + plate_plan = self.plate_page.widget.value() + if plate_plan.plate != calib_plan.plate: # pragma: no cover + warnings.warn("Plate Plan and Calibration Plan do not match.", stacklevel=2) + return None + + return useq.WellPlatePlan( + plate=plate_plan.plate, + selected_wells=plate_plan.selected_wells, + rotation=calib_plan.rotation, + a1_center_xy=calib_plan.a1_center_xy, + well_points_plan=self.points_plan_page.widget.value(), + ) + + def setValue(self, value: useq.WellPlatePlan) -> None: + """Set the state of the wizard to a WellPlatePlan.""" + self.plate_page.widget.setValue(value) + self.calibration_page.widget.setValue(value) + # update the points plan fov size if it's not set + point_plan = value.well_points_plan + if point_plan.fov_width is None or point_plan.fov_height is None: + point_plan.fov_width, point_plan.fov_height = ( + self.points_plan_page._get_fov_size() + ) + self.points_plan_page.widget.setValue(point_plan) + + def save(self, path: str | None = None) -> None: + """Save the current well plate plan to disk.""" + if not isinstance(path, str): + path, _ = QFileDialog.getSaveFileName( + self, "Save Well Plate Plan", "", "JSON (*.json)" + ) + elif not path.endswith(".json"): # pragma: no cover + raise ValueError("Path must end with '.json'") + if path and (value := self.value()): + txt = value.model_dump_json(exclude_unset=True, indent=2) + Path(path).write_text(txt) + + def load(self, path: str | None = None) -> None: + """Load a well plate plan from disk.""" + if not isinstance(path, str): + path, _ = QFileDialog.getOpenFileName( + self, "Load Well Plate Plan", "", "JSON (*.json)" + ) + if path: + self.setValue(WellPlatePlan.from_file(path)) + + def _on_plate_changed(self, plate_plan: useq.WellPlatePlan) -> None: + """Synchronize the points plan with the well size/shape.""" + # update the calibration widget with the new plate if it's different + current_calib_plan = self.calibration_page.widget.value() + if current_calib_plan is None or current_calib_plan.plate != plate_plan.plate: + self.calibration_page.widget.setValue(plate_plan.plate) + + pp_widget = self.points_plan_page.widget + + # set the well size on the points plan widget to the current plate well size + well_width, well_height = plate_plan.plate.well_size + pp_widget.setWellSize(well_width, well_height) + + # additionally, restrict the max width and height of the random points widget + # to the plate size minus the fov size. + fovw = pp_widget._selector.fov_w.value() + fovh = pp_widget._selector.fov_h.value() + + # if the random points shape is a rectangle, but the wells are circular, + # reduce the max width and height by 1.4 to keep the points inside the wells + random_wdg = pp_widget.random_points_wdg + if random_wdg.shape.currentText() == useq.Shape.RECTANGLE.value: + if plate_plan.plate.circular_wells: + well_width /= 1.4 + well_height /= 1.4 + + random_wdg.max_width.setMaximum(well_width * 1000) + random_wdg.max_width.setValue(well_width * 1000 - fovw / 1.4) + random_wdg.max_height.setMaximum(well_height * 1000) + random_wdg.max_height.setValue(well_height * 1000 - fovh / 1.4) + + def _on_calibration_changed(self, calibrated: bool) -> None: + self._calibrated = calibrated + self.button(QWizard.WizardButton.CustomButton1).setEnabled(calibrated) + + +# ---------------------------------- PAGES --------------------------------------- + + +class _PlatePage(QWizardPage): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + + self.setTitle("Plate and Well Selection") + + self.widget = WellPlateWidget() + self.widget.setShowRotation(False) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.widget) + + +class _PlateCalibrationPage(QWizardPage): + def __init__(self, mmcore: CMMCorePlus, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setTitle("Plate Calibration") + + self._is_complete = False + self.widget = PlateCalibrationWidget(mmcore=mmcore) + self.widget.calibrationChanged.connect(self._on_calibration_changed) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.widget) + + def isComplete(self) -> bool: + return self._is_complete + + def _on_calibration_changed(self, calibrated: bool) -> None: + self._is_complete = calibrated + self.completeChanged.emit() + + +class _PointsPlanPage(QWizardPage): + def __init__(self, mmcore: CMMCorePlus, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._mmc = mmcore + self.setTitle("Field of View Selection") + + self.widget = PointsPlanWidget() + self.widget._selector.fov_widgets.setEnabled(False) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.widget) + + self._mmc.events.pixelSizeChanged.connect(self._on_px_size_changed) + self._mmc.events.systemConfigurationLoaded.connect(self._on_px_size_changed) + self._on_px_size_changed() + + def _on_px_size_changed(self) -> None: + val = self.widget.value() + val.fov_width, val.fov_height = self._get_fov_size() + self.widget.setValue(val) + + def _get_fov_size(self) -> tuple[float, float] | tuple[None, None]: + with suppress(RuntimeError): + if self._mmc.getCameraDevice() and (px := self._mmc.getPixelSizeUm()): + return self._mmc.getImageWidth() * px, self._mmc.getImageHeight() * px + return (None, None) diff --git a/src/pymmcore_widgets/hcs/_plate_calibration_widget.py b/src/pymmcore_widgets/hcs/_plate_calibration_widget.py index f64c0133d..651cb7264 100644 --- a/src/pymmcore_widgets/hcs/_plate_calibration_widget.py +++ b/src/pymmcore_widgets/hcs/_plate_calibration_widget.py @@ -8,6 +8,7 @@ from pymmcore_plus import CMMCorePlus from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( + QGroupBox, QHBoxLayout, QLabel, QPushButton, @@ -44,6 +45,9 @@ def __init__( self._mmc = mmcore or CMMCorePlus.instance() self._current_plate: useq.WellPlate | None = None + self._a1_center_xy: tuple[float, float] = (0.0, 0.0) + self._well_spacing: tuple[float, float] | None = None + self._rotation: float | None = None # minimum number of wells required to be calibrated # before the plate is considered calibrated self._min_wells_required: int = 3 @@ -74,6 +78,7 @@ def __init__( self._tab_wdg.setTabEnabled(1, False) self._test_well_btn = QPushButton("Test Well", self) + self._test_well_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) self._test_well_btn.setEnabled(False) # mapping of well index (r, c) to calibration widget @@ -83,20 +88,21 @@ def __init__( self._info = QLabel("Please calibrate at least three wells.") self._info_icon = QLabel() - self._update_info(None) + self._update_info() # LAYOUT ------------------------------------------------------------- - right_layout = QVBoxLayout() - right_layout.setContentsMargins(6, 0, 0, 0) + right_wdg = QGroupBox() + right_layout = QVBoxLayout(right_wdg) + right_layout.setContentsMargins(5, 5, 5, 5) right_layout.addWidget(self._calibration_widget_stack) right_layout.addWidget(SeparatorWidget()) right_layout.addWidget(self._test_well_btn) right_layout.addStretch() top = QHBoxLayout() + top.addWidget(right_wdg, 0) top.addWidget(self._tab_wdg, 1) - top.addLayout(right_layout) info_layout = QHBoxLayout() info_layout.addWidget(self._info_icon, 0) @@ -115,12 +121,14 @@ def __init__( # ---------------------------PUBLIC API----------------------------------- - def setValue(self, plate: str | useq.WellPlate | useq.WellPlatePlan) -> None: + def setValue( + self, plate_or_plan: str | useq.WellPlate | useq.WellPlatePlan + ) -> None: """Set the plate to be calibrated. Parameters ---------- - plate : str | useq.WellPlate | useq.WellPlatePlan + plate_or_plan : str | useq.WellPlate | useq.WellPlatePlan The well plate to calibrate. If a string, it is assumed to be the name of a well plate (e.g. "96-well"). If a WellPlate instance, it is used directly. If a WellPlatePlan instance, the plate is set to the plan's plate and the @@ -128,13 +136,17 @@ def setValue(self, plate: str | useq.WellPlate | useq.WellPlatePlan) -> None: """ calibrated: bool = False plan: useq.WellPlatePlan | None = None - - if isinstance(plate, str): - plate = useq.WellPlate.from_str(plate) - elif isinstance(plate, useq.WellPlatePlan): - plan = plate - plate = plate.plate + plate: useq.WellPlate | None = None + if isinstance(plate_or_plan, str): + plate = useq.WellPlate.from_str(plate_or_plan) + elif isinstance(plate_or_plan, useq.WellPlatePlan): + self._a1_center_xy = plate_or_plan.a1_center_xy + self._rotation = plate_or_plan.rotation + plan = plate_or_plan + plate = plan.plate calibrated = True + elif isinstance(plate_or_plan, useq.WellPlate): + plate = plate_or_plan self._current_plate = plate self._plate_view.drawPlate(plate) @@ -149,26 +161,21 @@ def setValue(self, plate: str | useq.WellPlate | useq.WellPlatePlan) -> None: if calibrated and plan is not None: self._plate_test.drawPlate(plan) - self._update_info((plan.a1_center_xy, plate.well_spacing, plan.rotation)) else: self._plate_test.clear() - self._update_info(None) + self._update_info() self._tab_wdg.setTabEnabled(1, calibrated) self.calibrationChanged.emit(calibrated) def value(self) -> useq.WellPlatePlan | None: """Return the plate plan with calibration information.""" - a1_center_xy = (0.0, 0.0) - rotation: float = 0.0 - if (osr := self._origin_spacing_rotation()) is not None: - a1_center_xy, (unit_x, unit_y), rotation = osr if self._current_plate is None: # pragma: no cover return None return useq.WellPlatePlan( plate=self._current_plate, - a1_center_xy=a1_center_xy, - rotation=rotation, + a1_center_xy=self._a1_center_xy, + rotation=self._rotation, ) # ---------------------------PRIVATE API---------------------------------- @@ -321,26 +328,27 @@ def _on_well_calibration_changed(self, calibrated: bool) -> None: self._plate_view.setWellColor(*idx, None) osr = self._origin_spacing_rotation() - fully_calibrated = osr is not None - self._update_info(osr) - - if fully_calibrated and self._current_plate: - self._plate_test.drawPlate(self._current_plate) + if fully_calibrated := (osr is not None): + self._a1_center_xy, self._well_spacing, self._rotation = osr + if self._current_plate: + self._plate_test.drawPlate(self._current_plate) else: + self._a1_center_xy = (0.0, 0.0) + self._rotation = None + self._well_spacing = None self._plate_test.clear() + self._update_info() self._tab_wdg.setTabEnabled(1, fully_calibrated) self.calibrationChanged.emit(fully_calibrated) - def _update_info( - self, osr: tuple[tuple[float, float], tuple[float, float], float | None] | None - ) -> None: + def _update_info(self) -> None: style = self.style() - if osr is not None: + if self._rotation is not None: + spacing = self._well_spacing or (0, 0) txt = "Plate calibrated." ico = icon(CALIBRATED_ICON, color=GREEN) if self._current_plate is not None: - origin, spacing, rotation = osr spacing_diff = abs(spacing[0] - self._current_plate.well_spacing[0]) # if spacing is more than 5% different from the plate spacing... if spacing_diff > 0.05 * self._current_plate.well_spacing[0]: @@ -351,9 +359,10 @@ def _update_info( ) ico = style.standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning) txt += "
" - txt += f"\nA1 Center [mm]: ({origin[0]/1000:.2f}, {origin[1]/1000:.2f}), " + x0, y0 = self._a1_center_xy + txt += f"\nA1 Center [mm]: ({x0/1000:.2f}, {y0/1000:.2f}), " txt += f"Well Spacing [mm]: ({spacing[0]:.2f}, {spacing[1]:.2f}), " - txt += f"Rotation: {rotation if rotation is not None else 0}°" + txt += f"Rotation: {self._rotation}°" elif len(self._calibrated_wells) < self._min_wells_required: txt = f"Please calibrate at least {self._min_wells_required} wells." ico = style.standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation) diff --git a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py index 4ab5d44df..31c18d706 100644 --- a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py +++ b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py @@ -89,9 +89,11 @@ def __init__( # plate view self._view = WellPlateView(self) - self._show_rotation = QCheckBox("Show Rotation", self._view) - self._show_rotation.move(6, 6) - self._show_rotation.hide() + self._show_rotation_cb = QCheckBox("Show Rotation", self._view) + self._show_rotation_cb.setStyleSheet("background: transparent;") + self._show_rotation_cb.move(6, 6) + self._show_rotation_cb.hide() + self._show_rotation = True # LAYOUT --------------------------------------- @@ -111,12 +113,30 @@ def __init__( self._view.selectionChanged.connect(self._on_value_changed) self._clear_button.clicked.connect(self._view.clearSelection) self.plate_name.currentTextChanged.connect(self._on_plate_name_changed) - self._show_rotation.toggled.connect(self._on_show_rotation_toggled) + self._show_rotation_cb.toggled.connect(self._update_view) - self.setValue(plan or self.value()) + if plan: + self.setValue(plan) + else: + self.setValue(self.value()) # _________________________PUBLIC METHODS_________________________ # + def setShowRotation(self, allow: bool) -> None: + """Set whether to allow visible rotation of the well plate. + + If `allow` is False, the rotation checkbox is hidden and the rotation is + never shown. If True, the checkbox is shown and the user can toggle the + rotation on/off. + """ + self._show_rotation = allow + if not allow: + self._show_rotation_cb.hide() + self._show_rotation_cb.setChecked(False) + elif self._rotation: + self._show_rotation_cb.show() + self._show_rotation_cb.setChecked(True) + def value(self) -> useq.WellPlatePlan: """Return the current plate and the selected wells as a `useq.WellPlatePlan`.""" return useq.WellPlatePlan( @@ -145,14 +165,18 @@ def setValue(self, value: useq.WellPlatePlan | useq.WellPlate | Mapping) -> None self._a1_center_xy = plan.a1_center_xy with signals_blocked(self): self.plate_name.setCurrentText(plan.plate.name) - self._view.drawPlate(plan) + self._update_view(plan) - if plan.rotation: - self._show_rotation.show() - self._show_rotation.setChecked(True) + if self._show_rotation and plan.rotation: + self._show_rotation_cb.show() else: - self._show_rotation.hide() - self._show_rotation.setChecked(False) + self._show_rotation_cb.hide() + + def _update_view(self, value: bool | useq.WellPlatePlan | None = None) -> None: + rot = self._rotation if self._show_rotation_cb.isChecked() else None + plan = value if isinstance(value, useq.WellPlatePlan) else self.value() + val = plan.model_copy(update={"rotation": rot}) + self._view.drawPlate(val) def currentSelection(self) -> tuple[tuple[int, int], ...]: """Return the indices of the selected wells as `((row, col), ...)`.""" @@ -182,14 +206,11 @@ def _on_value_changed(self) -> None: self.valueChanged.emit(self.value()) def _on_plate_name_changed(self, plate_name: str) -> None: + self._view.clearSelection() plate = useq.WellPlate.from_str(plate_name) val = self.value().model_copy(update={"plate": plate, "selected_wells": None}) self.setValue(val) - - def _on_show_rotation_toggled(self, checked: bool) -> None: - rot = self._rotation if checked else None - val = self.value().model_copy(update={"rotation": rot}) - self._view.drawPlate(val) + self.valueChanged.emit(self.value()) class HoverEllipse(QGraphicsEllipseItem): @@ -204,6 +225,9 @@ def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent | None) -> None: """Update color and position when hovering over the well.""" self.setCursor(Qt.CursorShape.PointingHandCursor) self.setBrush(self._selected_color) + # update tooltip font color + tooltip = self.toolTip() + self.setToolTip(f"{tooltip}") super().hoverEnterEvent(event) def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent | None) -> None: @@ -298,9 +322,7 @@ def _on_rubber_band_changed(self, rect: QRect) -> None: select.add(item) else: deselect.add(item) - with signals_blocked(self): - self._select_items(select) - self._deselect_items(deselect) + self._change_selection(select, deselect) def mousePressEvent(self, event: QMouseEvent | None) -> None: if event and event.button() == Qt.MouseButton.LeftButton: @@ -325,13 +347,13 @@ def mouseReleaseEvent(self, event: QMouseEvent | None) -> None: for item in self.items(event.pos()): if item == self._pressed_item: if self._pressed_item.data(DATA_SELECTED) is True: - self._deselect_items((self._pressed_item,)) + self._change_selection((), (self._pressed_item,)) else: if self._selection_mode == self.SelectionMode.SingleSelection: # deselect all other items - self._deselect_items(self._selected_items) + self._change_selection((), self._selected_items) if self._selection_mode != self.SelectionMode.NoSelection: - self._select_items((self._pressed_item,)) + self._change_selection((self._pressed_item,), ()) break self._pressed_item = None @@ -369,13 +391,11 @@ def setSelectedIndices(self, indices: Iterable[tuple[int, int]]) -> None: select.add(item) else: deselect.add(item) - with signals_blocked(self): - self._select_items(select) - self._deselect_items(deselect) + self._change_selection(select, deselect) def clearSelection(self) -> None: """Clear the current selection.""" - self._deselect_items(self._selected_items) + self._change_selection((), self._selected_items) def clear(self) -> None: """Clear all the wells from the view.""" @@ -492,6 +512,8 @@ def _add_preset_positions_items( new_pos = useq.Position(x=x, y=y, name=pos.name) item = HoverEllipse(edge_rect) item.setData(DATA_POSITION, new_pos) + if new_pos.x is not None and new_pos.y is not None: + item.setToolTip(f"{new_pos.name} ({new_pos.x:.0f}, {new_pos.y:.0f})") item.setZValue(1) # make sure it's on top self._scene.addItem(item) self._well_edge_spots.append(item) @@ -500,22 +522,28 @@ def _resize_to_fit(self) -> None: self.setSceneRect(self._scene.itemsBoundingRect()) self.resizeEvent(None) - def _select_items(self, items: Iterable[QAbstractGraphicsShapeItem]) -> None: - for item in items: + def _change_selection( + self, + select: Iterable[QAbstractGraphicsShapeItem], + deselect: Iterable[QAbstractGraphicsShapeItem], + ) -> None: + before = self._selected_items.copy() + + for item in select: color = item.data(DATA_COLOR) or self._selected_color item.setBrush(color) item.setData(DATA_SELECTED, True) - self._selected_items.update(items) - self.selectionChanged.emit() + self._selected_items.update(select) - def _deselect_items(self, items: Iterable[QAbstractGraphicsShapeItem]) -> None: - for item in items: + for item in deselect: if item.data(DATA_SELECTED): color = item.data(DATA_COLOR) or self._unselected_color item.setBrush(color) item.setData(DATA_SELECTED, False) - self._selected_items.difference_update(items) - self.selectionChanged.emit() + self._selected_items.difference_update(deselect) + + if before != self._selected_items: + self.selectionChanged.emit() def sizeHint(self) -> QSize: """Provide a reasonable size hint with aspect ratio of a well plate.""" diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py index 4a398e42a..4c24c3817 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py @@ -36,10 +36,12 @@ def __init__(self, parent: QWidget | None = None) -> None: self.rows = QSpinBox() self.rows.setAlignment(Qt.AlignmentFlag.AlignCenter) self.rows.setMinimum(1) + self.rows.setValue(3) # columns self.columns = QSpinBox() self.columns.setAlignment(Qt.AlignmentFlag.AlignCenter) self.columns.setMinimum(1) + self.columns.setValue(3) # overlap along x self.overlap_x = QDoubleSpinBox() self.overlap_x.setAlignment(Qt.AlignmentFlag.AlignCenter) diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py index e7433c248..104744fb0 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py @@ -132,7 +132,7 @@ def __init__(self, parent: QWidget | None = None) -> None: layout.setSpacing(5) layout.addWidget(btn, 0) layout.addWidget(grpbx, 1) - main_layout.addLayout(layout) + main_layout.addLayout(layout, 1) # FOV widgets go at the bottom, and are combined into a single widget # for ease of showing/hiding the whole thing at once @@ -144,10 +144,7 @@ def __init__(self, parent: QWidget | None = None) -> None: fov_layout.addWidget(QLabel("FOV (w, h; µm):")) fov_layout.addWidget(self.fov_w) fov_layout.addWidget(self.fov_h) - main_layout.addWidget(self.fov_widgets) - - # for i in range(1, 5, 2): - # main_layout.insertWidget(i, SeparatorWidget()) + main_layout.addWidget(self.fov_widgets, 0) # _________________________PUBLIC METHODS_________________________ # diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py index 7e8d5f64e..f9a4dbba6 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py @@ -39,6 +39,11 @@ def __init__( super().__init__(parent=parent) self._selector = RelativePointPlanSelector() + # aliases + self.single_pos_wdg = self._selector.single_pos_wdg + self.random_points_wdg = self._selector.random_points_wdg + self.grid_wdg = self._selector.grid_wdg + # graphics scene to draw the well and the fovs self._well_view = WellView() @@ -88,9 +93,9 @@ def _on_selector_value_changed(self, value: useq.RelativeMultiPointPlan) -> None self.valueChanged.emit(value) def _on_view_max_points_detected(self, value: int) -> None: - self._selector.random_points_wdg.num_points.setValue(value) + self.random_points_wdg.num_points.setValue(value) def _on_view_position_clicked(self, position: useq.RelativePosition) -> None: if self._selector.active_plan_type is useq.RandomPoints: pos_no_name = position.model_copy(update={"name": ""}) - self._selector.random_points_wdg.start_at = pos_no_name + self.random_points_wdg.start_at = pos_no_name diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py index 8c91cee44..ee635fb49 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py @@ -54,6 +54,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self.num_points = QSpinBox() self.num_points.setAlignment(Qt.AlignmentFlag.AlignCenter) self.num_points.setRange(1, 1000) + self.num_points.setValue(10) # order combobox self.order = QComboBox() self.order.addItems([mode.value for mode in TraversalOrder]) diff --git a/tests/hcs/test_hcs_wizard.py b/tests/hcs/test_hcs_wizard.py new file mode 100644 index 000000000..0cb47c4d3 --- /dev/null +++ b/tests/hcs/test_hcs_wizard.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import useq + +from pymmcore_widgets.hcs import HCSWizard + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + from pytestqt.qtbot import QtBot + + +def test_hcs_wizard(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: + """Test the HCSWizard.""" + + plan = useq.WellPlatePlan( + plate="96-well", + a1_center_xy=(1000, 1500), + rotation=0.3, + selected_wells=slice(0, 8, 2), + ) + + wdg = HCSWizard(mmcore=global_mmcore) + wdg.setValue(plan) + qtbot.addWidget(wdg) + wdg.show() + wdg.next() + wdg.next() + wdg.next() + wdg.accept() + + # we haven't done anything, the plan should be the same + assert wdg.value() == plan diff --git a/tests/hcs/test_well_plate_calibration_widget.py b/tests/hcs/test_well_plate_calibration_widget.py index 00967c8a5..53b8a0f63 100644 --- a/tests/hcs/test_well_plate_calibration_widget.py +++ b/tests/hcs/test_well_plate_calibration_widget.py @@ -5,6 +5,14 @@ from pymmcore_widgets.useq_widgets._well_plate_widget import DATA_POSITION +def test_plate_calibration_value(global_mmcore: CMMCorePlus, qtbot) -> None: + wdg = PlateCalibrationWidget(mmcore=global_mmcore) + qtbot.addWidget(wdg) + plan = useq.WellPlatePlan(plate="96-well", a1_center_xy=(10, 20), rotation=2) + wdg.setValue(plan) + assert wdg.value() == plan + + def test_plate_calibration(global_mmcore: CMMCorePlus, qtbot) -> None: wdg = PlateCalibrationWidget(mmcore=global_mmcore) wdg.show() diff --git a/tests/useq_widgets/test_useq_points_plans.py b/tests/useq_widgets/test_useq_points_plans.py index 65533cd2c..e5037ae4f 100644 --- a/tests/useq_widgets/test_useq_points_plans.py +++ b/tests/useq_widgets/test_useq_points_plans.py @@ -45,7 +45,7 @@ def test_random_points_widget(qtbot: QtBot) -> None: wdg = pp.RandomPointWidget() qtbot.addWidget(wdg) - assert wdg.num_points.value() == 1 + assert wdg.num_points.value() == 10 assert wdg.max_width.value() == 6000 assert wdg.max_height.value() == 6000 assert wdg.shape.currentText() == "ellipse" @@ -69,8 +69,8 @@ def test_random_points_widget(qtbot: QtBot) -> None: def test_grid_plan_widget(qtbot: QtBot) -> None: wdg = pp.GridRowColumnWidget() qtbot.addWidget(wdg) - assert wdg.rows.value() == 1 - assert wdg.columns.value() == 1 + assert wdg.rows.value() == 3 + assert wdg.columns.value() == 3 assert wdg.overlap_x.value() == 0 assert wdg.overlap_y.value() == 0 assert wdg.mode.currentText() == "row_wise_snake" @@ -199,7 +199,7 @@ def test_max_points_detected(qtbot: QtBot) -> None: qtbot.addWidget(wdg) with qtbot.waitSignal(wdg._well_view.maxPointsDetected): - wdg._selector.random_points_wdg.num_points.setValue(100) + wdg.random_points_wdg.num_points.setValue(100) assert wdg.value().num_points < 60 From ecabdf3460d5b142242abc6c0ba7b7e7acce97ae Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Tue, 1 Oct 2024 14:05:21 -0400 Subject: [PATCH 22/38] fix: enable ct axis order (#361) --- src/pymmcore_widgets/useq_widgets/_mda_sequence.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pymmcore_widgets/useq_widgets/_mda_sequence.py b/src/pymmcore_widgets/useq_widgets/_mda_sequence.py index 6a9378e8d..ffb00fee7 100644 --- a/src/pymmcore_widgets/useq_widgets/_mda_sequence.py +++ b/src/pymmcore_widgets/useq_widgets/_mda_sequence.py @@ -51,7 +51,6 @@ def _check_order(x: str, first: str, second: str) -> bool: ALLOWED_ORDERS = {"".join(p) for x in range(1, 6) for p in permutations(AXES, x)} for x in list(ALLOWED_ORDERS): for first, second in ( - ("t", "c"), # t cannot come after c ("t", "z"), # t cannot come after z ("p", "g"), # p cannot come after g ("p", "c"), # p cannot come after c From 7b074d30ebda5259457f8aee50b923c60b5ecd78 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:37:54 -0400 Subject: [PATCH 23/38] feat: add HCSWizard to MDAWIdget (#362) * PlateDatabaseWidget * delete some stuff * add example * running * wip * lots of cleanup * style(pre-commit.ci): auto fixes [...] * more tweaks * feat: wip PlateTestWidget * fix: wip * feat: click wip * feat: working stage movements + example * feat: preset movements * fix: _xy_label * fix: SELECTED_MOVE_TO_COLOR * fix: _MoveToItem size and color * fix: refactor * fix: adjust position if rotation * fix: invert rotated_y sign * fix: rename + fix _PresetPositionItem size * fix: update example with rotation * fix: remove print * test: wip * fix: use qtpy * test: update * test: update * fix: remove print * test: update * test: update * test: fix * fix: move test into hcs folder * fix: rename * fix: clear * fix: setPlate * fix: update layout * fix: margins * fix: spacing * feat: add well name to _xy_label * fix: spacing * unifying features * feat: calibration tab + _test_well_btn * fix: use _current_calibration_widget * fix: rename + random test points * fix: wip * changes * fix: update setPlate method * fix: refactor * fix: remove unused * fix: _add_preset_positions_items * fix: _get_random_edge_point * test: update tests * test: update tests * style(pre-commit.ci): auto fixes [...] * feat: add new calibration wdg * fix: rename methods * add _calibrated flag * rename to value and setValue * fix _on_plate_changed] * todo * add label * change click signal * minor * fix: add QGroupBox * fix: sizes + rotation * fix: accept * fix: HoverEllipse tooltip * fix: accept * fix: example * fix: example * fix: deselect wells with no explicit selection * undo last change * more changes in behavior * some docs * remove WellPlateWidgetNoRotation * supress runtime error * updates * fix: clear selection when plate changes * fix: emit signal when selecting a different plate * fix: update fov size when calling setValue * fix: test_well_btn no focus policy * fix: fix bug when setting selected_wells * test: fix * feat: start adding HCSWizard * feat: add hcs edit positions btn * fix: z column * fix: move logic to core position table * test: start adding tests * test: more tests * feat: add position overwrite warning dialog * Update src/pymmcore_widgets/mda/_core_positions.py * undo change * Refactor HCS wizard integration in CoreConnectedPositionTable * fix test * more updates * test: add failing test * fix: fix signal emitted too many times * fix: keep AF checkbox visible * test: update * test: comment --------- Co-authored-by: Talley Lambert Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- examples/mda_widget.py | 5 + src/pymmcore_widgets/mda/_core_mda.py | 18 ++ src/pymmcore_widgets/mda/_core_positions.py | 183 +++++++++++++++++- .../useq_widgets/_positions.py | 20 +- tests/test_useq_core_widgets.py | 129 +++++++++++- 5 files changed, 334 insertions(+), 21 deletions(-) diff --git a/examples/mda_widget.py b/examples/mda_widget.py index a0d01e34e..8379f03b4 100644 --- a/examples/mda_widget.py +++ b/examples/mda_widget.py @@ -3,12 +3,17 @@ It is fully connected to the CMMCorePlus object, and has a "run" button. """ +from contextlib import suppress + import useq from pymmcore_plus import CMMCorePlus from qtpy.QtWidgets import QApplication from pymmcore_widgets import MDAWidget +with suppress(ImportError): + from rich import print + app = QApplication([]) CMMCorePlus.instance().loadSystemConfiguration() diff --git a/src/pymmcore_widgets/mda/_core_mda.py b/src/pymmcore_widgets/mda/_core_mda.py index 18e7e71b2..ffc7b8c07 100644 --- a/src/pymmcore_widgets/mda/_core_mda.py +++ b/src/pymmcore_widgets/mda/_core_mda.py @@ -111,6 +111,24 @@ def __init__( self.destroyed.connect(self._disconnect) + # ----------- Override type hints in superclass ----------- + + @property + def channels(self) -> CoreConnectedChannelTable: + return cast("CoreConnectedChannelTable", self.tab_wdg.channels) + + @property + def z_plan(self) -> CoreConnectedZPlanWidget: + return cast("CoreConnectedZPlanWidget", self.tab_wdg.z_plan) + + @property + def stage_positions(self) -> CoreConnectedPositionTable: + return cast("CoreConnectedPositionTable", self.tab_wdg.stage_positions) + + @property + def grid_plan(self) -> CoreConnectedGridPlanWidget: + return cast("CoreConnectedGridPlanWidget", self.tab_wdg.grid_plan) + # ------------------- public Methods ---------------------- def value(self) -> MDASequence: diff --git a/src/pymmcore_widgets/mda/_core_positions.py b/src/pymmcore_widgets/mda/_core_positions.py index 349245f1a..aac73b8c5 100644 --- a/src/pymmcore_widgets/mda/_core_positions.py +++ b/src/pymmcore_widgets/mda/_core_positions.py @@ -1,20 +1,37 @@ from __future__ import annotations -from typing import Any +from contextlib import suppress +from typing import TYPE_CHECKING, Any, Sequence from fonticon_mdi6 import MDI6 from pymmcore_plus import CMMCorePlus from pymmcore_plus._logger import logger from pymmcore_plus._util import retry -from qtpy.QtWidgets import QCheckBox, QMessageBox, QWidget, QWidgetAction +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QCheckBox, + QMessageBox, + QPushButton, + QWidget, + QWidgetAction, + QWizard, +) from superqt.utils import signals_blocked +from useq import WellPlatePlan +from pymmcore_widgets import HCSWizard from pymmcore_widgets.useq_widgets import PositionTable from pymmcore_widgets.useq_widgets._column_info import ( ButtonColumn, ) from pymmcore_widgets.useq_widgets._positions import AF_DEFAULT_TOOLTIP +if TYPE_CHECKING: + from useq import Position + +UPDATE_POSITIONS = "Update Positions List" +ADD_POSITIONS = "Add to Positions List" + class CoreConnectedPositionTable(PositionTable): """[PositionTable](../PositionTable#) connected to a Micro-Manager core instance. @@ -45,6 +62,28 @@ def __init__( super().__init__(rows, parent) self._mmc = mmcore or CMMCorePlus.instance() + # -------------- HCS Wizard ---------------- + self._hcs_wizard: HCSWizard | None = None + self._plate_plan: WellPlatePlan | None = None + + self._hcs_button = QPushButton("Well Plate...") + # self._hcs_button.setIcon(icon(MDI6.view_comfy)) + self._hcs_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._hcs_button.setToolTip("Open the HCS wizard.") + self._hcs_button.clicked.connect(self._show_hcs) + + self._edit_hcs_pos = QPushButton("Make Editable") + self._edit_hcs_pos.setToolTip( + "Convert HCS positions to regular editable positions." + ) + self._edit_hcs_pos.setStyleSheet("color: red") + self._edit_hcs_pos.hide() + self._edit_hcs_pos.clicked.connect(self._show_pos_editing_dialog) + + self._btn_row.insertWidget(3, self._hcs_button) + self._btn_row.insertWidget(3, self._edit_hcs_pos) + # ------------------------------------------ + self.move_to_selection = QCheckBox("Move Stage to Selected Point") # add a button to update XY to the current position self._xy_btn_col = ButtonColumn( @@ -53,19 +92,20 @@ def __init__( self._z_btn_col = ButtonColumn( key="z_btn", glyph=MDI6.arrow_left, on_click=self._set_z_from_core ) - self.table().addColumn(self._xy_btn_col, self.table().indexOf(self.X)) - self.table().addColumn(self._z_btn_col, self.table().indexOf(self.Z) + 1) - self.table().addColumn(self._af_btn_col, self.table().indexOf(self.AF) + 1) + table = self.table() + table.addColumn(self._xy_btn_col, table.indexOf(self.X)) + table.addColumn(self._z_btn_col, table.indexOf(self.Z) + 1) + table.addColumn(self._af_btn_col, table.indexOf(self.AF) + 1) # when a new row is inserted, call _on_rows_inserted # to update the new values from the core position - self.table().model().rowsInserted.connect(self._on_rows_inserted) + table.model().rowsInserted.connect(self._on_rows_inserted) # add move_to_selection to toolbar and link up callback toolbar = self.toolBar() action0 = next(x for x in toolbar.children() if isinstance(x, QWidgetAction)) toolbar.insertWidget(action0, self.move_to_selection) - self.table().itemSelectionChanged.connect(self._on_selection_change) + table.itemSelectionChanged.connect(self._on_selection_change) # connect self._mmc.events.systemConfigurationLoaded.connect(self._on_sys_config_loaded) @@ -77,8 +117,137 @@ def __init__( # hide the set-AF-offset button to begin with. self._on_af_per_position_toggled(self.af_per_position.isChecked()) + # ---------------------- public methods ----------------------- + + def value( + self, exclude_unchecked: bool = True, exclude_hidden_cols: bool = True + ) -> tuple[Position, ...] | WellPlatePlan: + """Return the current state of the positions table.""" + if self._plate_plan is not None: + return self._plate_plan + return super().value(exclude_unchecked, exclude_hidden_cols) + + def setValue(self, value: Sequence[Position] | WellPlatePlan) -> None: + """Set the value of the positions table.""" + if isinstance(value, WellPlatePlan): + self._plate_plan = value + self._hcs.setValue(value) + self._set_position_table_editable(False) + value = tuple(value) + super().setValue(value) + # ----------------------- private methods ----------------------- + def _show_hcs(self) -> None: + """Show or raise the HCS wizard.""" + self._hcs.raise_() if self._hcs.isVisible() else self._hcs.show() + + @property + def _hcs(self) -> HCSWizard: + """Get the HCS wizard, initializing it if it doesn't exist.""" + if self._hcs_wizard is None: + self._hcs_wizard = HCSWizard(self) + self._rename_hcs_position_button(ADD_POSITIONS) + self._hcs_wizard.accepted.connect(self._on_hcs_accepted) + return self._hcs_wizard + + def _on_hcs_accepted(self) -> None: + """Add the positions from the HCS wizard to the stage positions.""" + self._plate_plan = self._hcs.value() + if self._plate_plan is not None: + # show a ovwerwrite warning dialog if the table is not empty + if self.table().rowCount() > 0: + dialog = QMessageBox( + QMessageBox.Icon.Warning, + "Overwrite Positions", + "This will replace the positions currently stored in the table." + "\nWould you like to proceed?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + self, + ) + dialog.setDefaultButton(QMessageBox.StandardButton.Yes) + if dialog.exec() != QMessageBox.StandardButton.Yes: + return + self._update_table_positions(self._plate_plan) + + def _update_table_positions(self, plan: WellPlatePlan) -> None: + """Update the table with the positions from the HCS wizard.""" + self.setValue(list(plan)) + self._set_position_table_editable(False) + + def _rename_hcs_position_button(self, text: str) -> None: + if wiz := self._hcs_wizard: + wiz.points_plan_page.setButtonText(QWizard.WizardButton.FinishButton, text) + + def _show_pos_editing_dialog(self) -> None: + dialog = QMessageBox( + QMessageBox.Icon.Warning, + "Reset HCS", + "Positions are currently autogenerated from the HCS Wizard." + "\n\nWould you like to cast them to a list of stage positions?" + "\n\nNOTE: you will no longer be able to edit them using the HCS Wizard " + "widget.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + self, + ) + dialog.setDefaultButton(QMessageBox.StandardButton.No) + if dialog.exec() == QMessageBox.StandardButton.Yes: + self._plate_plan = None + self._set_position_table_editable(True) + + def _set_position_table_editable(self, state: bool) -> None: + """Enable/disable the position table depending on the use of the HCS wizard.""" + self._edit_hcs_pos.setVisible(not state) + self.include_z.setVisible(state) + + # Hide or show all columns that are irrelevant when using the HCS wizard + table = self.table() + inc_z = self.include_z.isChecked() + table.setColumnHidden(table.indexOf(self._xy_btn_col), not state) + table.setColumnHidden(table.indexOf(self._z_btn_col), not state or not inc_z) + table.setColumnHidden(table.indexOf(self.Z), not state or not inc_z) + table.setColumnHidden(table.indexOf(self.SEQ), not state) + + # Enable or disable the toolbar + for action in self.toolBar().actions()[1:]: + action.setEnabled(state) + + self._enable_table_items(state) + # connect/disconnect the double click event and rename the button + if state: + self._rename_hcs_position_button(ADD_POSITIONS) + with suppress(RuntimeError): + self.table().cellDoubleClicked.disconnect(self._show_pos_editing_dialog) + else: + self._rename_hcs_position_button(UPDATE_POSITIONS) + # using UniqueConnection to avoid multiple connections + # but catching the TypeError if the connection is already made + with suppress(TypeError, RuntimeError): + self.table().cellDoubleClicked.connect( + self._show_pos_editing_dialog, Qt.ConnectionType.UniqueConnection + ) + + def _enable_table_items(self, state: bool) -> None: + """Enable or disable the table items depending on the use of the HCS wizard.""" + table = self.table() + name_col = table.indexOf(self.NAME) + x_col = table.indexOf(self.X) + y_col = table.indexOf(self.Y) + with signals_blocked(table): + for row in range(table.rowCount()): + table.cellWidget(row, x_col).setEnabled(state) + table.cellWidget(row, y_col).setEnabled(state) + # enable/disable the name cells + name_item = table.item(row, name_col) + flags = name_item.flags() | Qt.ItemFlag.ItemIsEnabled + if state: + flags |= Qt.ItemFlag.ItemIsEditable + else: + # keep the name column enabled but NOT editable. We do not disable + # to keep available the "Move Stage to Selected Point" option + flags &= ~Qt.ItemFlag.ItemIsEditable + name_item.setFlags(flags) + def _on_sys_config_loaded(self) -> None: """Update the table when the system configuration is loaded.""" self._update_xy_enablement() diff --git a/src/pymmcore_widgets/useq_widgets/_positions.py b/src/pymmcore_widgets/useq_widgets/_positions.py index 57bcf7639..78f7f4c25 100644 --- a/src/pymmcore_widgets/useq_widgets/_positions.py +++ b/src/pymmcore_widgets/useq_widgets/_positions.py @@ -176,22 +176,22 @@ def __init__(self, rows: int = 0, parent: QWidget | None = None): self._load_button = QPushButton("Load...") self._load_button.clicked.connect(self.load) - btn_row = QHBoxLayout() - btn_row.setSpacing(15) - btn_row.addWidget(self.include_z) - btn_row.addWidget(self.af_per_position) - btn_row.addStretch() - btn_row.addWidget(self._save_button) - btn_row.addWidget(self._load_button) + self._btn_row = QHBoxLayout() + self._btn_row.setSpacing(15) + self._btn_row.addWidget(self.include_z) + self._btn_row.addWidget(self.af_per_position) + self._btn_row.addStretch() + self._btn_row.addWidget(self._save_button) + self._btn_row.addWidget(self._load_button) layout = cast("QVBoxLayout", self.layout()) - layout.addLayout(btn_row) + layout.addLayout(self._btn_row) # ------------------------- Public API ------------------------- def value( self, exclude_unchecked: bool = True, exclude_hidden_cols: bool = True - ) -> tuple[useq.Position, ...]: + ) -> Sequence[useq.Position]: """Return the current value of the table as a tuple of [useq.Position](https://pymmcore-plus.github.io/useq-schema/schema/axes/#useq.Position). Parameters @@ -235,7 +235,7 @@ def value( return tuple(out) - def setValue(self, value: Sequence[useq.Position]) -> None: # type: ignore + def setValue(self, value: Sequence[useq.Position]) -> None: # type: ignore [override] """Set the current value of the table from a Sequence of [useq.Position](https://pymmcore-plus.github.io/useq-schema/schema/axes/#useq.Position). Parameters diff --git a/tests/test_useq_core_widgets.py b/tests/test_useq_core_widgets.py index 250e7612e..63680d1f5 100644 --- a/tests/test_useq_core_widgets.py +++ b/tests/test_useq_core_widgets.py @@ -2,13 +2,14 @@ from pathlib import Path from typing import TYPE_CHECKING, Callable, cast -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest import useq from qtpy.QtCore import QTimer from qtpy.QtWidgets import QMessageBox +from pymmcore_widgets import HCSWizard from pymmcore_widgets._util import get_next_available_path from pymmcore_widgets.mda import MDAWidget from pymmcore_widgets.mda._core_channels import CoreConnectedChannelTable @@ -20,6 +21,7 @@ PYMMCW_METADATA_KEY, AutofocusAxis, KeepShutterOpen, + QFileDialog, ) from pymmcore_widgets.useq_widgets._positions import AF_DEFAULT_TOOLTIP, _MDAPopup @@ -238,7 +240,7 @@ def _getAutoFocusOffset(): def test_core_position_table_add_position( - qtbot: QtBot, mock_getAutoFocusOffset + qtbot: QtBot, mock_getAutoFocusOffset: None ) -> None: wdg = MDAWidget() qtbot.addWidget(wdg) @@ -579,8 +581,6 @@ def test_mda_no_pos_set(global_mmcore: CMMCorePlus, qtbot: QtBot): def test_core_mda_wdg_load_save( qtbot: QtBot, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ext: str ) -> None: - from pymmcore_widgets.useq_widgets._mda_sequence import QFileDialog - wdg = MDAWidget() qtbot.addWidget(wdg) wdg.show() @@ -686,3 +686,124 @@ def test_get_next_available_paths_special_cases(tmp_path: Path) -> None: high = tmp_path / "test_12345.txt" high.touch() assert get_next_available_path(high).name == "test_12346.txt" + + +def test_core_mda_with_hcs_value(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: + wdg = MDAWidget() + qtbot.addWidget(wdg) + wdg.show() + + # uncheck all tabs + for t in range(wdg.tab_wdg.count() + 1): + wdg.tab_wdg.setChecked(t, False) + + assert wdg.stage_positions._hcs_wizard is None + assert wdg.stage_positions._plate_plan is None + + pos = useq.WellPlatePlan( + plate="96-well", a1_center_xy=(0, 0), selected_wells=((0, 1), (0, 1)) + ) + seq = useq.MDASequence(stage_positions=pos) + + mock = Mock() + wdg.valueChanged.connect(mock) + wdg.setValue(seq) + mock.assert_called_once() + + assert wdg.value().stage_positions == pos + assert wdg.stage_positions.table().rowCount() == len(pos) + + assert isinstance(wdg.stage_positions._hcs_wizard, HCSWizard) + assert wdg.stage_positions._plate_plan == pos + + +def test_core_mda_with_hcs_enable_disable( + qtbot: QtBot, global_mmcore: CMMCorePlus +) -> None: + wdg = MDAWidget() + qtbot.addWidget(wdg) + wdg.show() + + table = wdg.stage_positions.table() + name_col = table.indexOf(wdg.stage_positions.NAME) + xy_btn_col = table.indexOf(wdg.stage_positions._xy_btn_col) + z_btn_col = table.indexOf(wdg.stage_positions._z_btn_col) + z_col = table.indexOf(wdg.stage_positions.Z) + sub_seq_btn_col = table.indexOf(wdg.stage_positions.SEQ) + + mda = useq.MDASequence(stage_positions=[(0, 0, 0), (1, 1, 1)]) + wdg.setValue(mda) + + # edit table btn is hidden + assert wdg.stage_positions._edit_hcs_pos.isHidden() + # all table visible + assert not table.isColumnHidden(name_col) + assert not table.isColumnHidden(xy_btn_col) + assert not table.isColumnHidden(z_btn_col) + assert not table.isColumnHidden(z_col) + assert not table.isColumnHidden(sub_seq_btn_col) + # all toolbar actions enabled + assert all(action.isEnabled() for action in wdg.stage_positions.toolBar().actions()) + # include_z checkbox enabled + assert wdg.stage_positions.include_z.isEnabled() + # autofocus checkbox enabled + assert wdg.stage_positions.af_per_position.isEnabled() + + mda = useq.MDASequence( + stage_positions=useq.WellPlatePlan( + plate="96-well", + a1_center_xy=(0, 0), + selected_wells=((0, 1), (0, 1)), + ) + ) + wdg.setValue(mda) + + # edit table btn is visible + assert not wdg.stage_positions._edit_hcs_pos.isHidden() + # all columns hidden but name + assert not table.isColumnHidden(name_col) + assert table.isColumnHidden(xy_btn_col) + assert table.isColumnHidden(z_btn_col) + assert table.isColumnHidden(z_col) + assert table.isColumnHidden(sub_seq_btn_col) + # all toolbar actions disabled but the move stage checkbox + assert all( + not action.isEnabled() for action in wdg.stage_positions.toolBar().actions()[1:] + ) + # include_z checkbox disablex + assert wdg.stage_positions.include_z.isHidden() + # autofocus checkbox enabled + assert wdg.stage_positions.af_per_position.isEnabled() + + +@pytest.mark.parametrize("ext", ["json", "yaml"]) +def test_core_mda_with_hcs_load_save( + qtbot: QtBot, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ext: str +) -> None: + wdg = MDAWidget() + qtbot.addWidget(wdg) + wdg.show() + + dest = tmp_path / f"sequence.{ext}" + # monkeypatch the dialog to load/save to our temp file + monkeypatch.setattr(QFileDialog, "getSaveFileName", lambda *a: (dest, None)) + monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *a: (dest, None)) + + # write the sequence to file and load the widget from it + mda = MDA.replace( + stage_positions=useq.WellPlatePlan( + plate="96-well", + a1_center_xy=(0, 0), + selected_wells=((0, 0), (1, 1)), + well_points_plan=useq.RelativePosition(fov_width=512.0, fov_height=512.0), + ) + ) + dest.write_text(mda.yaml() if ext == "yaml" else mda.model_dump_json()) + wdg.load() + + pos = wdg.value().stage_positions + + # save the widget to file and load it back + dest.unlink() + wdg.save() + assert useq.MDASequence.from_file(dest).stage_positions == pos From 2c7aa1ae6a6273936e832e5438bec45eb66c024e Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Fri, 4 Oct 2024 11:59:40 -0400 Subject: [PATCH 24/38] fix: disable Autofocus checkbox when using HCSWizard (#364) --- src/pymmcore_widgets/mda/_core_positions.py | 1 + tests/test_useq_core_widgets.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pymmcore_widgets/mda/_core_positions.py b/src/pymmcore_widgets/mda/_core_positions.py index aac73b8c5..2d423b321 100644 --- a/src/pymmcore_widgets/mda/_core_positions.py +++ b/src/pymmcore_widgets/mda/_core_positions.py @@ -199,6 +199,7 @@ def _set_position_table_editable(self, state: bool) -> None: """Enable/disable the position table depending on the use of the HCS wizard.""" self._edit_hcs_pos.setVisible(not state) self.include_z.setVisible(state) + self.af_per_position.setVisible(state) # Hide or show all columns that are irrelevant when using the HCS wizard table = self.table() diff --git a/tests/test_useq_core_widgets.py b/tests/test_useq_core_widgets.py index 63680d1f5..ee8795b4e 100644 --- a/tests/test_useq_core_widgets.py +++ b/tests/test_useq_core_widgets.py @@ -770,10 +770,10 @@ def test_core_mda_with_hcs_enable_disable( assert all( not action.isEnabled() for action in wdg.stage_positions.toolBar().actions()[1:] ) - # include_z checkbox disablex + # include_z checkbox disabled assert wdg.stage_positions.include_z.isHidden() - # autofocus checkbox enabled - assert wdg.stage_positions.af_per_position.isEnabled() + # autofocus checkbox disabled + assert wdg.stage_positions.af_per_position.isHidden() @pytest.mark.parametrize("ext", ["json", "yaml"]) From e52a8bc703ccaffb79d531f2f67fa0d9a5a916ba Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 4 Oct 2024 12:27:44 -0400 Subject: [PATCH 25/38] fix: fix splitting logic and deduplicate code in Groups Presets Widgets (#365) * fix: fix splitting logic and deduplicate code * remove conditionals --- .../_add_first_preset_widget.py | 47 ++--------------- .../_add_preset_widget.py | 51 +++---------------- .../_group_preset_widget/_cfg_table.py | 47 +++++++++++++++++ .../_edit_preset_widget.py | 51 ++++--------------- 4 files changed, 69 insertions(+), 127 deletions(-) create mode 100644 src/pymmcore_widgets/_group_preset_widget/_cfg_table.py diff --git a/src/pymmcore_widgets/_group_preset_widget/_add_first_preset_widget.py b/src/pymmcore_widgets/_group_preset_widget/_add_first_preset_widget.py index 34818c7a4..cb1a540b0 100644 --- a/src/pymmcore_widgets/_group_preset_widget/_add_first_preset_widget.py +++ b/src/pymmcore_widgets/_group_preset_widget/_add_first_preset_widget.py @@ -1,7 +1,6 @@ from __future__ import annotations from pymmcore_plus import CMMCorePlus -from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QDialog, QGroupBox, @@ -11,15 +10,14 @@ QPushButton, QSizePolicy, QSpacerItem, - QTableWidget, - QTableWidgetItem, QVBoxLayout, QWidget, ) -from pymmcore_widgets._property_widget import PropertyWidget from pymmcore_widgets._util import block_core +from ._cfg_table import _CfgTable + class AddFirstPresetWidget(QDialog): """A widget to create the first specified group's preset.""" @@ -39,7 +37,7 @@ def __init__( self._create_gui() - self._populate_table() + self.table.populate_table(self._dev_prop_val_list) def _create_gui(self) -> None: self.setWindowTitle(f"Add the first Preset to the new '{self._group}' Group") @@ -58,7 +56,7 @@ def _create_gui(self) -> None: top_wdg = self._create_top_wdg() wdg_layout.addWidget(top_wdg) - self.table = _Table() + self.table = _CfgTable() wdg_layout.addWidget(self.table) bottom_wdg = self._create_bottom_wdg() @@ -121,26 +119,8 @@ def _create_bottom_wdg(self) -> QWidget: return wdg - def _populate_table(self) -> None: - self.table.clearContents() - - self.table.setRowCount(len(self._dev_prop_val_list)) - for idx, (dev, prop, _) in enumerate(self._dev_prop_val_list): - item = QTableWidgetItem(f"{dev}-{prop}") - wdg = PropertyWidget(dev, prop, mmcore=self._mmc) - wdg._value_widget.valueChanged.disconnect() # type: ignore - self.table.setItem(idx, 0, item) - self.table.setCellWidget(idx, 1, wdg) - def _create_first_preset(self) -> None: - dev_prop_val = [] - for row in range(self.table.rowCount()): - device_property = self.table.item(row, 0).text() - dev = device_property.split("-")[0] - prop = device_property.split("-")[1] - value = str(self.table.cellWidget(row, 1).value()) - dev_prop_val.append((dev, prop, value)) - + dev_prop_val = self.table.get_state() preset = self.preset_name_lineedit.text() if not preset: preset = self.preset_name_lineedit.placeholderText() @@ -153,20 +133,3 @@ def _create_first_preset(self) -> None: self.close() self.parent().close() - - -class _Table(QTableWidget): - """Set table properties for EditPresetWidget.""" - - def __init__(self) -> None: - super().__init__() - hdr = self.horizontalHeader() - hdr.setSectionResizeMode(hdr.ResizeMode.Stretch) - hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignHCenter) - vh = self.verticalHeader() - vh.setVisible(False) - vh.setSectionResizeMode(vh.ResizeMode.Fixed) - vh.setDefaultSectionSize(24) - self.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self.setColumnCount(2) - self.setHorizontalHeaderLabels(["Device-Property", "Value"]) diff --git a/src/pymmcore_widgets/_group_preset_widget/_add_preset_widget.py b/src/pymmcore_widgets/_group_preset_widget/_add_preset_widget.py index 155629acc..6b65a3ed5 100644 --- a/src/pymmcore_widgets/_group_preset_widget/_add_preset_widget.py +++ b/src/pymmcore_widgets/_group_preset_widget/_add_preset_widget.py @@ -3,7 +3,6 @@ import warnings from pymmcore_plus import CMMCorePlus -from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QDialog, QGroupBox, @@ -13,15 +12,14 @@ QPushButton, QSizePolicy, QSpacerItem, - QTableWidget, - QTableWidgetItem, QVBoxLayout, QWidget, ) -from pymmcore_widgets._property_widget import PropertyWidget from pymmcore_widgets._util import block_core +from ._cfg_table import _CfgTable + class AddPresetWidget(QDialog): """A widget to add presets to a specified group.""" @@ -53,7 +51,7 @@ def _create_gui(self) -> None: top_wdg = self._create_top_wdg() wdg_layout.addWidget(top_wdg) - self.table = _Table() + self.table = _CfgTable() wdg_layout.addWidget(self.table) bottom_wdg = self._create_bottom_wdg() @@ -114,8 +112,6 @@ def _create_bottom_wdg(self) -> QWidget: return wdg def _populate_table(self) -> None: - self.table.clearContents() - dev_prop = [] for preset in self._mmc.getAvailableConfigs(self._group): dev_prop.extend( @@ -125,14 +121,7 @@ def _populate_table(self) -> None: if (k[0], k[1]) not in dev_prop ] ) - - self.table.setRowCount(len(dev_prop)) - for idx, (dev, prop) in enumerate(dev_prop): - item = QTableWidgetItem(f"{dev}-{prop}") - wdg = PropertyWidget(dev, prop, mmcore=self._mmc) - wdg._value_widget.valueChanged.disconnect() # type: ignore - self.table.setItem(idx, 0, item) - self.table.setCellWidget(idx, 1, wdg) + self.table.populate_table(dev_prop) def _add_preset(self) -> None: preset_name = self.preset_name_lineedit.text() @@ -148,14 +137,7 @@ def _add_preset(self) -> None: if not preset_name: preset_name = self.preset_name_lineedit.placeholderText() - dev_prop_val = [] - for row in range(self.table.rowCount()): - device_property = self.table.item(row, 0).text() - dev = device_property.split("-")[0] - prop = device_property.split("-")[1] - value = str(self.table.cellWidget(row, 1).value()) - dev_prop_val.append((dev, prop, value)) - + dev_prop_val = self.table.get_state() for p in self._mmc.getAvailableConfigs(self._group): dpv_preset = [ (k[0], k[1], k[2]) for k in self._mmc.getConfigData(self._group, p) @@ -171,28 +153,11 @@ def _add_preset(self) -> None: return with block_core(self._mmc.events): - for d, p, v in dev_prop_val: - self._mmc.defineConfig(self._group, preset_name, d, p, v) + for dev, prop, val in dev_prop_val: + self._mmc.defineConfig(self._group, preset_name, dev, prop, val) - self._mmc.events.configDefined.emit(self._group, preset_name, d, p, v) + self._mmc.events.configDefined.emit(self._group, preset_name, dev, prop, val) self.info_lbl.setStyleSheet("") self.info_lbl.setText(f"'{preset_name}' has been added!") self.preset_name_lineedit.setPlaceholderText(self._get_placeholder_name()) - - -class _Table(QTableWidget): - """Set table properties for EditPresetWidget.""" - - def __init__(self) -> None: - super().__init__() - hdr = self.horizontalHeader() - hdr.setSectionResizeMode(hdr.ResizeMode.Stretch) - hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignHCenter) - vh = self.verticalHeader() - vh.setVisible(False) - vh.setSectionResizeMode(vh.ResizeMode.Fixed) - vh.setDefaultSectionSize(24) - self.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self.setColumnCount(2) - self.setHorizontalHeaderLabels(["Device-Property", "Value"]) diff --git a/src/pymmcore_widgets/_group_preset_widget/_cfg_table.py b/src/pymmcore_widgets/_group_preset_widget/_cfg_table.py new file mode 100644 index 000000000..ae72d97e8 --- /dev/null +++ b/src/pymmcore_widgets/_group_preset_widget/_cfg_table.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Any, Sequence, cast + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QTableWidget, QTableWidgetItem + +from pymmcore_widgets._property_widget import PropertyWidget + +DEV_PROP_ROLE = Qt.ItemDataRole.UserRole + 1 + + +class _CfgTable(QTableWidget): + """Set table properties for EditPresetWidget.""" + + def __init__(self) -> None: + super().__init__() + hdr = self.horizontalHeader() + hdr.setSectionResizeMode(hdr.ResizeMode.Stretch) + hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignHCenter) + vh = self.verticalHeader() + vh.setVisible(False) + vh.setSectionResizeMode(vh.ResizeMode.Fixed) + vh.setDefaultSectionSize(24) + self.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.setColumnCount(2) + self.setHorizontalHeaderLabels(["Device-Property", "Value"]) + + def populate_table(self, dev_prop_val: Sequence[Sequence[Any]]) -> None: + self.clearContents() + self.setRowCount(len(dev_prop_val)) + for idx, (dev, prop, *_) in enumerate(dev_prop_val): + item = QTableWidgetItem(f"{dev}-{prop}") + item.setData(DEV_PROP_ROLE, (dev, prop)) + wdg = PropertyWidget(dev, prop, connect_core=False) + self.setItem(idx, 0, item) + self.setCellWidget(idx, 1, wdg) + + def get_state(self) -> list[tuple[str, str, str]]: + dev_prop_val = [] + for row in range(self.rowCount()): + if (dev_prop_item := self.item(row, 0)) and ( + wdg := cast("PropertyWidget", self.cellWidget(row, 1)) + ): + dev, prop = dev_prop_item.data(DEV_PROP_ROLE) + dev_prop_val.append((dev, prop, str(wdg.value()))) + return dev_prop_val diff --git a/src/pymmcore_widgets/_group_preset_widget/_edit_preset_widget.py b/src/pymmcore_widgets/_group_preset_widget/_edit_preset_widget.py index 4b868c679..ffb04d347 100644 --- a/src/pymmcore_widgets/_group_preset_widget/_edit_preset_widget.py +++ b/src/pymmcore_widgets/_group_preset_widget/_edit_preset_widget.py @@ -3,7 +3,6 @@ import warnings from pymmcore_plus import CMMCorePlus -from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QComboBox, QDialog, @@ -14,16 +13,15 @@ QPushButton, QSizePolicy, QSpacerItem, - QTableWidget, - QTableWidgetItem, QVBoxLayout, QWidget, ) from superqt.utils import signals_blocked -from pymmcore_widgets._property_widget import PropertyWidget from pymmcore_widgets._util import block_core +from ._cfg_table import _CfgTable + class EditPresetWidget(QDialog): """A widget to edit a specified group's presets.""" @@ -60,7 +58,7 @@ def _create_gui(self) -> None: # sourcery skip: class-extract-method top_wdg = self._create_top_wdg() wdg_layout.addWidget(top_wdg) - self.table = _Table() + self.table = _CfgTable() wdg_layout.addWidget(self.table) bottom_wdg = self._create_bottom_wdg() @@ -151,20 +149,12 @@ def _resize_combo_height(self, max_items: int) -> None: def _populate_table_and_combo(self) -> None: self._update_combo() - self.table.clearContents() - dev_prop_val = [ (k[0], k[1], k[2]) for k in self._mmc.getConfigData(self._group, self._preset) ] - self.table.setRowCount(len(dev_prop_val)) - for idx, (dev, prop, val) in enumerate(dev_prop_val): - item = QTableWidgetItem(f"{dev}-{prop}") - wdg = PropertyWidget(dev, prop, mmcore=self._mmc) - wdg._value_widget.valueChanged.disconnect() # type: ignore - wdg.setValue(val) - self.table.setItem(idx, 0, item) - self.table.setCellWidget(idx, 1, wdg) + + self.table.populate_table(dev_prop_val) def _on_combo_changed(self, preset: str) -> None: self._preset = preset @@ -173,13 +163,7 @@ def _on_combo_changed(self, preset: str) -> None: self._populate_table_and_combo() def _apply_changes(self) -> None: - dev_prop_val = [] - for row in range(self.table.rowCount()): - device_property = self.table.item(row, 0).text() - dev = device_property.split("-")[0] - prop = device_property.split("-")[1] - value = str(self.table.cellWidget(row, 1).value()) - dev_prop_val.append((dev, prop, value)) + dev_prop_val = self.table.get_state() for p in self._mmc.getAvailableConfigs(self._group): dpv_preset = [ @@ -205,29 +189,12 @@ def _apply_changes(self) -> None: self._preset = self.preset_name_lineedit.text() with block_core(self._mmc.events): - for d, p, v in dev_prop_val: - self._mmc.defineConfig(self._group, self._preset, d, p, v) + for dev, prop, val in dev_prop_val: + self._mmc.defineConfig(self._group, self._preset, dev, prop, val) - self._mmc.events.configDefined.emit(self._group, self._preset, d, p, v) + self._mmc.events.configDefined.emit(self._group, self._preset, dev, prop, val) self._update_combo() self.info_lbl.setStyleSheet("") self.info_lbl.setText(f"'{self._preset}' has been modified!") - - -class _Table(QTableWidget): - """Set table properties for EditPresetWidget.""" - - def __init__(self) -> None: - super().__init__() - hdr = self.horizontalHeader() - hdr.setSectionResizeMode(hdr.ResizeMode.Stretch) - hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignHCenter) - vh = self.verticalHeader() - vh.setVisible(False) - vh.setSectionResizeMode(vh.ResizeMode.Fixed) - vh.setDefaultSectionSize(24) - self.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self.setColumnCount(2) - self.setHorizontalHeaderLabels(["Device-Property", "Value"]) From c29afc4cb92f6b9807e4ba4bb2b13bc04630e230 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 4 Oct 2024 13:38:15 -0400 Subject: [PATCH 26/38] refactor: full repo reorganization (#366) * refactor: full repo reorganization * fixes * typing --- src/pymmcore_widgets/__init__.py | 135 +++++++++++------- src/pymmcore_widgets/_core.py | 19 --- .../__init__.py | 0 .../{ => _deprecated}/_device_widget.py | 0 src/pymmcore_widgets/_icons.py | 24 ++++ src/pymmcore_widgets/_install_widget.py | 3 - src/pymmcore_widgets/_util.py | 14 ++ .../config_presets/__init__.py | 11 ++ .../_group_preset_widget/__init__.py | 15 ++ .../_add_first_preset_widget.py | 0 .../_group_preset_widget/_add_group_widget.py | 6 +- .../_add_preset_widget.py | 0 .../_group_preset_widget/_cfg_table.py | 2 +- .../_edit_group_widget.py | 6 +- .../_edit_preset_widget.py | 0 .../_group_preset_table_widget.py | 7 +- .../_objectives_pixel_configuration_widget.py | 2 +- .../_pixel_configuration_widget.py | 8 +- src/pymmcore_widgets/control/__init__.py | 28 ++++ .../{ => control}/_camera_roi_widget.py | 0 .../{ => control}/_channel_group_widget.py | 0 .../{ => control}/_channel_widget.py | 3 +- .../{ => control}/_exposure_widget.py | 0 .../{ => control}/_live_button_widget.py | 0 .../{ => control}/_load_system_cfg_widget.py | 2 +- .../{ => control}/_objective_widget.py | 4 +- .../{ => control}/_presets_widget.py | 2 +- .../{ => control}/_shutter_widget.py | 0 .../{ => control}/_snap_button_widget.py | 0 .../{ => control}/_stage_widget.py | 0 .../device_properties/__init__.py | 7 + .../_device_property_table.py | 25 +--- .../_device_type_filter.py | 0 .../_properties_widget.py | 0 .../_property_browser.py | 0 .../_property_widget.py | 0 src/pymmcore_widgets/experimental.py | 4 +- .../hcwizard/_simple_prop_table.py | 2 +- src/pymmcore_widgets/hcwizard/devices_page.py | 2 +- src/pymmcore_widgets/views/__init__.py | 5 + .../{ => views}/_image_widget.py | 0 .../{ => views}/_stack_viewer/__init__.py | 0 .../{ => views}/_stack_viewer/_channel_row.py | 0 .../{ => views}/_stack_viewer/_datastore.py | 0 .../_stack_viewer/_labeled_slider.py | 0 .../{ => views}/_stack_viewer/_save_button.py | 0 .../_stack_viewer/_stack_viewer.py | 0 tests/test_camera_roi_widget.py | 2 +- tests/test_channel_widget.py | 4 +- tests/test_core_state.py | 2 - tests/test_datastore.py | 2 +- tests/test_device_widget.py | 8 +- tests/test_group_preset_widget.py | 10 +- tests/test_live_button.py | 2 +- tests/test_load_system_cfg_widget.py | 2 +- tests/test_objective_pixel_config_widget.py | 2 +- tests/test_objective_widget.py | 2 +- tests/test_pixel_config_widget.py | 2 +- tests/test_presets_widget.py | 2 +- tests/test_shutter_widget.py | 2 +- tests/test_snap_button_widget.py | 2 +- tests/test_stack_viewer.py | 2 +- tests/test_stage_widget.py | 2 +- 63 files changed, 241 insertions(+), 143 deletions(-) delete mode 100644 src/pymmcore_widgets/_core.py rename src/pymmcore_widgets/{_group_preset_widget => _deprecated}/__init__.py (100%) rename src/pymmcore_widgets/{ => _deprecated}/_device_widget.py (100%) create mode 100644 src/pymmcore_widgets/_icons.py create mode 100644 src/pymmcore_widgets/config_presets/__init__.py create mode 100644 src/pymmcore_widgets/config_presets/_group_preset_widget/__init__.py rename src/pymmcore_widgets/{ => config_presets}/_group_preset_widget/_add_first_preset_widget.py (100%) rename src/pymmcore_widgets/{ => config_presets}/_group_preset_widget/_add_group_widget.py (96%) rename src/pymmcore_widgets/{ => config_presets}/_group_preset_widget/_add_preset_widget.py (100%) rename src/pymmcore_widgets/{ => config_presets}/_group_preset_widget/_cfg_table.py (95%) rename src/pymmcore_widgets/{ => config_presets}/_group_preset_widget/_edit_group_widget.py (97%) rename src/pymmcore_widgets/{ => config_presets}/_group_preset_widget/_edit_preset_widget.py (100%) rename src/pymmcore_widgets/{ => config_presets}/_group_preset_widget/_group_preset_table_widget.py (98%) rename src/pymmcore_widgets/{ => config_presets}/_objectives_pixel_configuration_widget.py (99%) rename src/pymmcore_widgets/{ => config_presets}/_pixel_configuration_widget.py (99%) create mode 100644 src/pymmcore_widgets/control/__init__.py rename src/pymmcore_widgets/{ => control}/_camera_roi_widget.py (100%) rename src/pymmcore_widgets/{ => control}/_channel_group_widget.py (100%) rename src/pymmcore_widgets/{ => control}/_channel_widget.py (99%) rename src/pymmcore_widgets/{ => control}/_exposure_widget.py (100%) rename src/pymmcore_widgets/{ => control}/_live_button_widget.py (100%) rename src/pymmcore_widgets/{ => control}/_load_system_cfg_widget.py (97%) rename src/pymmcore_widgets/{ => control}/_objective_widget.py (97%) rename src/pymmcore_widgets/{ => control}/_presets_widget.py (99%) rename src/pymmcore_widgets/{ => control}/_shutter_widget.py (100%) rename src/pymmcore_widgets/{ => control}/_snap_button_widget.py (100%) rename src/pymmcore_widgets/{ => control}/_stage_widget.py (100%) create mode 100644 src/pymmcore_widgets/device_properties/__init__.py rename src/pymmcore_widgets/{ => device_properties}/_device_property_table.py (91%) rename src/pymmcore_widgets/{ => device_properties}/_device_type_filter.py (100%) rename src/pymmcore_widgets/{ => device_properties}/_properties_widget.py (100%) rename src/pymmcore_widgets/{ => device_properties}/_property_browser.py (100%) rename src/pymmcore_widgets/{ => device_properties}/_property_widget.py (100%) create mode 100644 src/pymmcore_widgets/views/__init__.py rename src/pymmcore_widgets/{ => views}/_image_widget.py (100%) rename src/pymmcore_widgets/{ => views}/_stack_viewer/__init__.py (100%) rename src/pymmcore_widgets/{ => views}/_stack_viewer/_channel_row.py (100%) rename src/pymmcore_widgets/{ => views}/_stack_viewer/_datastore.py (100%) rename src/pymmcore_widgets/{ => views}/_stack_viewer/_labeled_slider.py (100%) rename src/pymmcore_widgets/{ => views}/_stack_viewer/_save_button.py (100%) rename src/pymmcore_widgets/{ => views}/_stack_viewer/_stack_viewer.py (100%) diff --git a/src/pymmcore_widgets/__init__.py b/src/pymmcore_widgets/__init__.py index 8a468a0f2..799cb1b44 100644 --- a/src/pymmcore_widgets/__init__.py +++ b/src/pymmcore_widgets/__init__.py @@ -2,32 +2,68 @@ import warnings from importlib.metadata import PackageNotFoundError, version +from typing import TYPE_CHECKING try: __version__ = version("pymmcore-widgets") except PackageNotFoundError: __version__ = "uninstalled" -from ._camera_roi_widget import CameraRoiWidget -from ._channel_group_widget import ChannelGroupWidget -from ._channel_widget import ChannelWidget -from ._device_widget import DeviceWidget, StateDeviceWidget -from ._exposure_widget import DefaultCameraExposureWidget, ExposureWidget -from ._group_preset_widget._group_preset_table_widget import GroupPresetTableWidget -from ._image_widget import ImagePreview +__all__ = [ + "CameraRoiWidget", + "ChannelGroupWidget", + "ChannelTable", + "ChannelWidget", + "ConfigurationWidget", + "ConfigWizard", + "DefaultCameraExposureWidget", + "DeviceWidget", + "ExposureWidget", + "GridPlanWidget", + "GroupPresetTableWidget", + "HCSWizard", + "ImagePreview", + "InstallWidget", + "LiveButton", + "MDAWidget", + "MDASequenceWidget", + "ObjectivesWidget", + "ObjectivesPixelConfigurationWidget", + "PixelConfigurationWidget", + "PositionTable", + "PresetsWidget", + "PropertiesWidget", + "PropertyBrowser", + "PropertyWidget", + "ShuttersWidget", + "SnapButton", + "StageWidget", + "StateDeviceWidget", + "TimePlanWidget", + "ZPlanWidget", +] + from ._install_widget import InstallWidget -from ._live_button_widget import LiveButton -from ._load_system_cfg_widget import ConfigurationWidget -from ._objective_widget import ObjectivesWidget -from ._objectives_pixel_configuration_widget import ObjectivesPixelConfigurationWidget -from ._pixel_configuration_widget import PixelConfigurationWidget -from ._presets_widget import PresetsWidget -from ._properties_widget import PropertiesWidget -from ._property_browser import PropertyBrowser -from ._property_widget import PropertyWidget -from ._shutter_widget import ShuttersWidget -from ._snap_button_widget import SnapButton -from ._stage_widget import StageWidget +from .config_presets import ( + GroupPresetTableWidget, + ObjectivesPixelConfigurationWidget, + PixelConfigurationWidget, +) +from .control import ( + CameraRoiWidget, + ChannelGroupWidget, + ChannelWidget, + ConfigurationWidget, + DefaultCameraExposureWidget, + ExposureWidget, + LiveButton, + ObjectivesWidget, + PresetsWidget, + ShuttersWidget, + SnapButton, + StageWidget, +) +from .device_properties import PropertiesWidget, PropertyBrowser, PropertyWidget from .hcs import HCSWizard from .hcwizard import ConfigWizard from .mda import MDAWidget @@ -39,9 +75,35 @@ TimePlanWidget, ZPlanWidget, ) +from .views import ImagePreview + +if TYPE_CHECKING: + from ._deprecated._device_widget import ( # noqa: TCH004 + DeviceWidget, + StateDeviceWidget, + ) def __getattr__(name: str) -> object: + if name == "DeviceWidget": + warnings.warn( + "'DeviceWidget' is deprecated, please seek alternatives.", + DeprecationWarning, + stacklevel=2, + ) + from ._deprecated._device_widget import DeviceWidget + + return DeviceWidget + if name == "StateDeviceWidget": + warnings.warn( + "'StateDeviceWidget' is deprecated, please seek alternatives.", + DeprecationWarning, + stacklevel=2, + ) + from ._deprecated._device_widget import StateDeviceWidget + + return StateDeviceWidget + if name == "ZStackWidget": warnings.warn( "'ZStackWidget' is deprecated, using 'ZPlanWidget' instead.", @@ -65,38 +127,3 @@ def __getattr__(name: str) -> object: ) return ObjectivesPixelConfigurationWidget raise AttributeError(f"module {__name__} has no attribute {name}") - - -__all__ = [ - "CameraRoiWidget", - "ChannelGroupWidget", - "ChannelTable", - "ChannelWidget", - "ConfigurationWidget", - "ConfigWizard", - "DefaultCameraExposureWidget", - "DeviceWidget", - "ExposureWidget", - "GridPlanWidget", - "GroupPresetTableWidget", - "HCSWizard", - "ImagePreview", - "InstallWidget", - "LiveButton", - "MDAWidget", - "MDASequenceWidget", - "ObjectivesWidget", - "ObjectivesPixelConfigurationWidget", - "PixelConfigurationWidget", - "PositionTable", - "PresetsWidget", - "PropertiesWidget", - "PropertyBrowser", - "PropertyWidget", - "ShuttersWidget", - "SnapButton", - "StageWidget", - "StateDeviceWidget", - "TimePlanWidget", - "ZPlanWidget", -] diff --git a/src/pymmcore_widgets/_core.py b/src/pymmcore_widgets/_core.py deleted file mode 100644 index 4ab372732..000000000 --- a/src/pymmcore_widgets/_core.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Functions and utils for managing the global mmcore singleton.""" - -from __future__ import annotations - -from pymmcore_plus import CMMCorePlus - - -def load_system_config(config: str = "", mmcore: CMMCorePlus | None = None) -> None: - """Internal convenience for `loadSystemConfiguration(config)`. - - This also unloads all devices first and resets the STATE. - If config is `None` or empty string, will load the MMConfig_demo. - Note that it should also always be fine for the end-user to use something like - `CMMCorePlus.instance().loadSystemConfiguration(...)` (instead of this function) - and we need to handle that as well. So this function shouldn't get too complex. - """ - mmc = mmcore or CMMCorePlus.instance() - mmc.unloadAllDevices() - mmc.loadSystemConfiguration(config or "MMConfig_demo.cfg") diff --git a/src/pymmcore_widgets/_group_preset_widget/__init__.py b/src/pymmcore_widgets/_deprecated/__init__.py similarity index 100% rename from src/pymmcore_widgets/_group_preset_widget/__init__.py rename to src/pymmcore_widgets/_deprecated/__init__.py diff --git a/src/pymmcore_widgets/_device_widget.py b/src/pymmcore_widgets/_deprecated/_device_widget.py similarity index 100% rename from src/pymmcore_widgets/_device_widget.py rename to src/pymmcore_widgets/_deprecated/_device_widget.py diff --git a/src/pymmcore_widgets/_icons.py b/src/pymmcore_widgets/_icons.py new file mode 100644 index 000000000..27dc2ef5e --- /dev/null +++ b/src/pymmcore_widgets/_icons.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from fonticon_mdi6 import MDI6 +from pymmcore_plus import DeviceType + +ICONS: dict[DeviceType, str] = { + DeviceType.Any: MDI6.devices, + DeviceType.AutoFocus: MDI6.auto_upload, + DeviceType.Camera: MDI6.camera, + DeviceType.Core: MDI6.checkbox_blank_circle_outline, + DeviceType.Galvo: MDI6.mirror_variant, + DeviceType.Generic: MDI6.dev_to, + DeviceType.Hub: MDI6.hubspot, + DeviceType.ImageProcessor: MDI6.image_auto_adjust, + DeviceType.Magnifier: MDI6.magnify_plus, + DeviceType.Shutter: MDI6.camera_iris, + DeviceType.SignalIO: MDI6.signal, + DeviceType.SLM: MDI6.view_comfy, + DeviceType.Stage: MDI6.arrow_up_down, + DeviceType.State: MDI6.state_machine, + DeviceType.Unknown: MDI6.dev_to, + DeviceType.XYStage: MDI6.arrow_all, + DeviceType.Serial: MDI6.serial_port, +} diff --git a/src/pymmcore_widgets/_install_widget.py b/src/pymmcore_widgets/_install_widget.py index 6264caf80..48533d7a1 100644 --- a/src/pymmcore_widgets/_install_widget.py +++ b/src/pymmcore_widgets/_install_widget.py @@ -25,9 +25,6 @@ LOC_ROLE = Qt.ItemDataRole.UserRole + 1 -# src/pymmcore_widgets/_install_widget.py -# 83-98, 101-105, 144-148, 152-170, 180-182, 186-199, 203-204, 209-214 - class InstallWidget(QWidget): """Widget to manage installation of MicroManager. diff --git a/src/pymmcore_widgets/_util.py b/src/pymmcore_widgets/_util.py index 251251d23..b312b90d0 100644 --- a/src/pymmcore_widgets/_util.py +++ b/src/pymmcore_widgets/_util.py @@ -208,3 +208,17 @@ def resizeEvent(self, event: QResizeEvent | None) -> None: margins = QMarginsF(xmargin, ymargin, xmargin, ymargin) self.fitInView(rect.marginsAdded(margins), Qt.AspectRatioMode.KeepAspectRatio) super().resizeEvent(event) + + +def load_system_config(config: str = "", mmcore: CMMCorePlus | None = None) -> None: + """Internal convenience for `loadSystemConfiguration(config)`. + + This also unloads all devices first and resets the STATE. + If config is `None` or empty string, will load the MMConfig_demo. + Note that it should also always be fine for the end-user to use something like + `CMMCorePlus.instance().loadSystemConfiguration(...)` (instead of this function) + and we need to handle that as well. So this function shouldn't get too complex. + """ + mmc = mmcore or CMMCorePlus.instance() + mmc.unloadAllDevices() + mmc.loadSystemConfiguration(config or "MMConfig_demo.cfg") diff --git a/src/pymmcore_widgets/config_presets/__init__.py b/src/pymmcore_widgets/config_presets/__init__.py new file mode 100644 index 000000000..b95ab363f --- /dev/null +++ b/src/pymmcore_widgets/config_presets/__init__.py @@ -0,0 +1,11 @@ +"""Widgets related to configuration groups and presets.""" + +from ._group_preset_widget._group_preset_table_widget import GroupPresetTableWidget +from ._objectives_pixel_configuration_widget import ObjectivesPixelConfigurationWidget +from ._pixel_configuration_widget import PixelConfigurationWidget + +__all__ = [ + "GroupPresetTableWidget", + "ObjectivesPixelConfigurationWidget", + "PixelConfigurationWidget", +] diff --git a/src/pymmcore_widgets/config_presets/_group_preset_widget/__init__.py b/src/pymmcore_widgets/config_presets/_group_preset_widget/__init__.py new file mode 100644 index 000000000..8c550e6a8 --- /dev/null +++ b/src/pymmcore_widgets/config_presets/_group_preset_widget/__init__.py @@ -0,0 +1,15 @@ +from ._add_first_preset_widget import AddFirstPresetWidget +from ._add_group_widget import AddGroupWidget +from ._add_preset_widget import AddPresetWidget +from ._edit_group_widget import EditGroupWidget +from ._edit_preset_widget import EditPresetWidget +from ._group_preset_table_widget import GroupPresetTableWidget + +__all__ = [ + "AddGroupWidget", + "AddFirstPresetWidget", + "AddPresetWidget", + "EditGroupWidget", + "EditPresetWidget", + "GroupPresetTableWidget", +] diff --git a/src/pymmcore_widgets/_group_preset_widget/_add_first_preset_widget.py b/src/pymmcore_widgets/config_presets/_group_preset_widget/_add_first_preset_widget.py similarity index 100% rename from src/pymmcore_widgets/_group_preset_widget/_add_first_preset_widget.py rename to src/pymmcore_widgets/config_presets/_group_preset_widget/_add_first_preset_widget.py diff --git a/src/pymmcore_widgets/_group_preset_widget/_add_group_widget.py b/src/pymmcore_widgets/config_presets/_group_preset_widget/_add_group_widget.py similarity index 96% rename from src/pymmcore_widgets/_group_preset_widget/_add_group_widget.py rename to src/pymmcore_widgets/config_presets/_group_preset_widget/_add_group_widget.py index 69c1e1fb6..2c607a18a 100644 --- a/src/pymmcore_widgets/_group_preset_widget/_add_group_widget.py +++ b/src/pymmcore_widgets/config_presets/_group_preset_widget/_add_group_widget.py @@ -16,8 +16,10 @@ QWidget, ) -from pymmcore_widgets._device_property_table import DevicePropertyTable -from pymmcore_widgets._device_type_filter import DeviceTypeFilters +from pymmcore_widgets.device_properties._device_property_table import ( + DevicePropertyTable, +) +from pymmcore_widgets.device_properties._device_type_filter import DeviceTypeFilters from ._add_first_preset_widget import AddFirstPresetWidget diff --git a/src/pymmcore_widgets/_group_preset_widget/_add_preset_widget.py b/src/pymmcore_widgets/config_presets/_group_preset_widget/_add_preset_widget.py similarity index 100% rename from src/pymmcore_widgets/_group_preset_widget/_add_preset_widget.py rename to src/pymmcore_widgets/config_presets/_group_preset_widget/_add_preset_widget.py diff --git a/src/pymmcore_widgets/_group_preset_widget/_cfg_table.py b/src/pymmcore_widgets/config_presets/_group_preset_widget/_cfg_table.py similarity index 95% rename from src/pymmcore_widgets/_group_preset_widget/_cfg_table.py rename to src/pymmcore_widgets/config_presets/_group_preset_widget/_cfg_table.py index ae72d97e8..5e8672b60 100644 --- a/src/pymmcore_widgets/_group_preset_widget/_cfg_table.py +++ b/src/pymmcore_widgets/config_presets/_group_preset_widget/_cfg_table.py @@ -5,7 +5,7 @@ from qtpy.QtCore import Qt from qtpy.QtWidgets import QTableWidget, QTableWidgetItem -from pymmcore_widgets._property_widget import PropertyWidget +from pymmcore_widgets.device_properties._property_widget import PropertyWidget DEV_PROP_ROLE = Qt.ItemDataRole.UserRole + 1 diff --git a/src/pymmcore_widgets/_group_preset_widget/_edit_group_widget.py b/src/pymmcore_widgets/config_presets/_group_preset_widget/_edit_group_widget.py similarity index 97% rename from src/pymmcore_widgets/_group_preset_widget/_edit_group_widget.py rename to src/pymmcore_widgets/config_presets/_group_preset_widget/_edit_group_widget.py index 7100f90a0..237238f3b 100644 --- a/src/pymmcore_widgets/_group_preset_widget/_edit_group_widget.py +++ b/src/pymmcore_widgets/config_presets/_group_preset_widget/_edit_group_widget.py @@ -13,9 +13,11 @@ QWidget, ) -from pymmcore_widgets._device_property_table import DevicePropertyTable -from pymmcore_widgets._device_type_filter import DeviceTypeFilters from pymmcore_widgets._util import block_core +from pymmcore_widgets.device_properties._device_property_table import ( + DevicePropertyTable, +) +from pymmcore_widgets.device_properties._device_type_filter import DeviceTypeFilters class EditGroupWidget(QDialog): diff --git a/src/pymmcore_widgets/_group_preset_widget/_edit_preset_widget.py b/src/pymmcore_widgets/config_presets/_group_preset_widget/_edit_preset_widget.py similarity index 100% rename from src/pymmcore_widgets/_group_preset_widget/_edit_preset_widget.py rename to src/pymmcore_widgets/config_presets/_group_preset_widget/_edit_preset_widget.py diff --git a/src/pymmcore_widgets/_group_preset_widget/_group_preset_table_widget.py b/src/pymmcore_widgets/config_presets/_group_preset_widget/_group_preset_table_widget.py similarity index 98% rename from src/pymmcore_widgets/_group_preset_widget/_group_preset_table_widget.py rename to src/pymmcore_widgets/config_presets/_group_preset_widget/_group_preset_table_widget.py index 3024d65e8..9fd44572a 100644 --- a/src/pymmcore_widgets/_group_preset_widget/_group_preset_table_widget.py +++ b/src/pymmcore_widgets/config_presets/_group_preset_widget/_group_preset_table_widget.py @@ -20,10 +20,9 @@ QWidget, ) -from pymmcore_widgets._core import load_system_config -from pymmcore_widgets._presets_widget import PresetsWidget -from pymmcore_widgets._property_widget import PropertyWidget -from pymmcore_widgets._util import block_core +from pymmcore_widgets._util import block_core, load_system_config +from pymmcore_widgets.control._presets_widget import PresetsWidget +from pymmcore_widgets.device_properties._property_widget import PropertyWidget from ._add_group_widget import AddGroupWidget from ._add_preset_widget import AddPresetWidget diff --git a/src/pymmcore_widgets/_objectives_pixel_configuration_widget.py b/src/pymmcore_widgets/config_presets/_objectives_pixel_configuration_widget.py similarity index 99% rename from src/pymmcore_widgets/_objectives_pixel_configuration_widget.py rename to src/pymmcore_widgets/config_presets/_objectives_pixel_configuration_widget.py index e961369e5..9fb6df066 100644 --- a/src/pymmcore_widgets/_objectives_pixel_configuration_widget.py +++ b/src/pymmcore_widgets/config_presets/_objectives_pixel_configuration_widget.py @@ -27,7 +27,7 @@ ) from superqt.utils import signals_blocked -from ._util import block_core, guess_objective_or_prompt +from pymmcore_widgets._util import block_core, guess_objective_or_prompt OBJECTIVE_LABEL = 0 RESOLUTION_ID = 1 diff --git a/src/pymmcore_widgets/_pixel_configuration_widget.py b/src/pymmcore_widgets/config_presets/_pixel_configuration_widget.py similarity index 99% rename from src/pymmcore_widgets/_pixel_configuration_widget.py rename to src/pymmcore_widgets/config_presets/_pixel_configuration_widget.py index aa341b894..cf85559ad 100644 --- a/src/pymmcore_widgets/_pixel_configuration_widget.py +++ b/src/pymmcore_widgets/config_presets/_pixel_configuration_widget.py @@ -27,9 +27,11 @@ ) from superqt.utils import signals_blocked -from pymmcore_widgets._device_property_table import DevicePropertyTable -from pymmcore_widgets._device_type_filter import DeviceTypeFilters -from pymmcore_widgets._property_widget import PropertyWidget +from pymmcore_widgets.device_properties._device_property_table import ( + DevicePropertyTable, +) +from pymmcore_widgets.device_properties._device_type_filter import DeviceTypeFilters +from pymmcore_widgets.device_properties._property_widget import PropertyWidget from pymmcore_widgets.useq_widgets import DataTable, DataTableWidget from pymmcore_widgets.useq_widgets._column_info import FloatColumn, TextColumn diff --git a/src/pymmcore_widgets/control/__init__.py b/src/pymmcore_widgets/control/__init__.py new file mode 100644 index 000000000..7310df571 --- /dev/null +++ b/src/pymmcore_widgets/control/__init__.py @@ -0,0 +1,28 @@ +"""Widgets that control various devices at runtime.""" + +from ._camera_roi_widget import CameraRoiWidget +from ._channel_group_widget import ChannelGroupWidget +from ._channel_widget import ChannelWidget +from ._exposure_widget import DefaultCameraExposureWidget, ExposureWidget +from ._live_button_widget import LiveButton +from ._load_system_cfg_widget import ConfigurationWidget +from ._objective_widget import ObjectivesWidget +from ._presets_widget import PresetsWidget +from ._shutter_widget import ShuttersWidget +from ._snap_button_widget import SnapButton +from ._stage_widget import StageWidget + +__all__ = [ + "CameraRoiWidget", + "ChannelGroupWidget", + "ChannelWidget", + "ConfigurationWidget", + "DefaultCameraExposureWidget", + "ExposureWidget", + "LiveButton", + "ObjectivesWidget", + "PresetsWidget", + "ShuttersWidget", + "SnapButton", + "StageWidget", +] diff --git a/src/pymmcore_widgets/_camera_roi_widget.py b/src/pymmcore_widgets/control/_camera_roi_widget.py similarity index 100% rename from src/pymmcore_widgets/_camera_roi_widget.py rename to src/pymmcore_widgets/control/_camera_roi_widget.py diff --git a/src/pymmcore_widgets/_channel_group_widget.py b/src/pymmcore_widgets/control/_channel_group_widget.py similarity index 100% rename from src/pymmcore_widgets/_channel_group_widget.py rename to src/pymmcore_widgets/control/_channel_group_widget.py diff --git a/src/pymmcore_widgets/_channel_widget.py b/src/pymmcore_widgets/control/_channel_widget.py similarity index 99% rename from src/pymmcore_widgets/_channel_widget.py rename to src/pymmcore_widgets/control/_channel_widget.py index 27979118f..50ed3b382 100644 --- a/src/pymmcore_widgets/_channel_widget.py +++ b/src/pymmcore_widgets/control/_channel_widget.py @@ -3,8 +3,9 @@ from pymmcore_plus import CMMCorePlus from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget +from pymmcore_widgets._util import ComboMessageBox + from ._presets_widget import PresetsWidget -from ._util import ComboMessageBox class ChannelWidget(QWidget): diff --git a/src/pymmcore_widgets/_exposure_widget.py b/src/pymmcore_widgets/control/_exposure_widget.py similarity index 100% rename from src/pymmcore_widgets/_exposure_widget.py rename to src/pymmcore_widgets/control/_exposure_widget.py diff --git a/src/pymmcore_widgets/_live_button_widget.py b/src/pymmcore_widgets/control/_live_button_widget.py similarity index 100% rename from src/pymmcore_widgets/_live_button_widget.py rename to src/pymmcore_widgets/control/_live_button_widget.py diff --git a/src/pymmcore_widgets/_load_system_cfg_widget.py b/src/pymmcore_widgets/control/_load_system_cfg_widget.py similarity index 97% rename from src/pymmcore_widgets/_load_system_cfg_widget.py rename to src/pymmcore_widgets/control/_load_system_cfg_widget.py index 01bbf1c28..2457863e0 100644 --- a/src/pymmcore_widgets/_load_system_cfg_widget.py +++ b/src/pymmcore_widgets/control/_load_system_cfg_widget.py @@ -9,7 +9,7 @@ QWidget, ) -from ._core import load_system_config +from pymmcore_widgets._util import load_system_config class ConfigurationWidget(QWidget): diff --git a/src/pymmcore_widgets/_objective_widget.py b/src/pymmcore_widgets/control/_objective_widget.py similarity index 97% rename from src/pymmcore_widgets/_objective_widget.py rename to src/pymmcore_widgets/control/_objective_widget.py index 68e92cde6..f8264d139 100644 --- a/src/pymmcore_widgets/_objective_widget.py +++ b/src/pymmcore_widgets/control/_objective_widget.py @@ -3,8 +3,8 @@ from pymmcore_plus import CMMCorePlus from qtpy.QtWidgets import QComboBox, QHBoxLayout, QLabel, QSizePolicy, QWidget -from ._device_widget import StateDeviceWidget -from ._util import guess_objective_or_prompt +from pymmcore_widgets._deprecated._device_widget import StateDeviceWidget +from pymmcore_widgets._util import guess_objective_or_prompt class ObjectivesWidget(QWidget): diff --git a/src/pymmcore_widgets/_presets_widget.py b/src/pymmcore_widgets/control/_presets_widget.py similarity index 99% rename from src/pymmcore_widgets/_presets_widget.py rename to src/pymmcore_widgets/control/_presets_widget.py index fdbb278ab..156aeb734 100644 --- a/src/pymmcore_widgets/_presets_widget.py +++ b/src/pymmcore_widgets/control/_presets_widget.py @@ -8,7 +8,7 @@ from qtpy.QtWidgets import QComboBox, QHBoxLayout, QWidget from superqt.utils import signals_blocked -from ._util import block_core +from pymmcore_widgets._util import block_core class PresetsWidget(QWidget): diff --git a/src/pymmcore_widgets/_shutter_widget.py b/src/pymmcore_widgets/control/_shutter_widget.py similarity index 100% rename from src/pymmcore_widgets/_shutter_widget.py rename to src/pymmcore_widgets/control/_shutter_widget.py diff --git a/src/pymmcore_widgets/_snap_button_widget.py b/src/pymmcore_widgets/control/_snap_button_widget.py similarity index 100% rename from src/pymmcore_widgets/_snap_button_widget.py rename to src/pymmcore_widgets/control/_snap_button_widget.py diff --git a/src/pymmcore_widgets/_stage_widget.py b/src/pymmcore_widgets/control/_stage_widget.py similarity index 100% rename from src/pymmcore_widgets/_stage_widget.py rename to src/pymmcore_widgets/control/_stage_widget.py diff --git a/src/pymmcore_widgets/device_properties/__init__.py b/src/pymmcore_widgets/device_properties/__init__.py new file mode 100644 index 000000000..ce53b040e --- /dev/null +++ b/src/pymmcore_widgets/device_properties/__init__.py @@ -0,0 +1,7 @@ +"""Widgets related to device properties.""" + +from ._properties_widget import PropertiesWidget +from ._property_browser import PropertyBrowser +from ._property_widget import PropertyWidget + +__all__ = ["PropertiesWidget", "PropertyBrowser", "PropertyWidget"] diff --git a/src/pymmcore_widgets/_device_property_table.py b/src/pymmcore_widgets/device_properties/_device_property_table.py similarity index 91% rename from src/pymmcore_widgets/_device_property_table.py rename to src/pymmcore_widgets/device_properties/_device_property_table.py index 9fa8473ba..aeffda804 100644 --- a/src/pymmcore_widgets/_device_property_table.py +++ b/src/pymmcore_widgets/device_properties/_device_property_table.py @@ -3,34 +3,15 @@ from logging import getLogger from typing import Iterable, cast -from fonticon_mdi6 import MDI6 from pymmcore_plus import CMMCorePlus, DeviceProperty, DeviceType from qtpy.QtCore import Qt from qtpy.QtGui import QColor from qtpy.QtWidgets import QAbstractScrollArea, QTableWidget, QTableWidgetItem, QWidget from superqt.fonticon import icon -from pymmcore_widgets._property_widget import PropertyWidget - -ICONS: dict[DeviceType, str] = { - DeviceType.Any: MDI6.devices, - DeviceType.AutoFocus: MDI6.auto_upload, - DeviceType.Camera: MDI6.camera, - DeviceType.Core: MDI6.checkbox_blank_circle_outline, - DeviceType.Galvo: MDI6.mirror_variant, - DeviceType.Generic: MDI6.dev_to, - DeviceType.Hub: MDI6.hubspot, - DeviceType.ImageProcessor: MDI6.image_auto_adjust, - DeviceType.Magnifier: MDI6.magnify_plus, - DeviceType.Shutter: MDI6.camera_iris, - DeviceType.SignalIO: MDI6.signal, - DeviceType.SLM: MDI6.view_comfy, - DeviceType.Stage: MDI6.arrow_up_down, - DeviceType.State: MDI6.state_machine, - DeviceType.Unknown: MDI6.dev_to, - DeviceType.XYStage: MDI6.arrow_all, - DeviceType.Serial: MDI6.serial_port, -} +from pymmcore_widgets._icons import ICONS + +from ._property_widget import PropertyWidget logger = getLogger(__name__) diff --git a/src/pymmcore_widgets/_device_type_filter.py b/src/pymmcore_widgets/device_properties/_device_type_filter.py similarity index 100% rename from src/pymmcore_widgets/_device_type_filter.py rename to src/pymmcore_widgets/device_properties/_device_type_filter.py diff --git a/src/pymmcore_widgets/_properties_widget.py b/src/pymmcore_widgets/device_properties/_properties_widget.py similarity index 100% rename from src/pymmcore_widgets/_properties_widget.py rename to src/pymmcore_widgets/device_properties/_properties_widget.py diff --git a/src/pymmcore_widgets/_property_browser.py b/src/pymmcore_widgets/device_properties/_property_browser.py similarity index 100% rename from src/pymmcore_widgets/_property_browser.py rename to src/pymmcore_widgets/device_properties/_property_browser.py diff --git a/src/pymmcore_widgets/_property_widget.py b/src/pymmcore_widgets/device_properties/_property_widget.py similarity index 100% rename from src/pymmcore_widgets/_property_widget.py rename to src/pymmcore_widgets/device_properties/_property_widget.py diff --git a/src/pymmcore_widgets/experimental.py b/src/pymmcore_widgets/experimental.py index 3a704297b..42d1da657 100644 --- a/src/pymmcore_widgets/experimental.py +++ b/src/pymmcore_widgets/experimental.py @@ -1,3 +1,5 @@ -from ._stack_viewer import StackViewer +"""Experimental widgets.""" + +from .views._stack_viewer import StackViewer __all__ = ["StackViewer"] diff --git a/src/pymmcore_widgets/hcwizard/_simple_prop_table.py b/src/pymmcore_widgets/hcwizard/_simple_prop_table.py index ca1b3bc2b..a1abc5cbb 100644 --- a/src/pymmcore_widgets/hcwizard/_simple_prop_table.py +++ b/src/pymmcore_widgets/hcwizard/_simple_prop_table.py @@ -8,7 +8,7 @@ from qtpy.QtCore import Signal from qtpy.QtWidgets import QComboBox, QTableWidget, QTableWidgetItem, QWidget -from pymmcore_widgets._property_widget import PropertyWidget +from pymmcore_widgets.device_properties._property_widget import PropertyWidget class PortSelector(QComboBox): diff --git a/src/pymmcore_widgets/hcwizard/devices_page.py b/src/pymmcore_widgets/hcwizard/devices_page.py index 62b0fdc2f..b35562208 100644 --- a/src/pymmcore_widgets/hcwizard/devices_page.py +++ b/src/pymmcore_widgets/hcwizard/devices_page.py @@ -26,7 +26,7 @@ from superqt.fonticon import icon, setTextIcon from superqt.utils import exceptions_as_dialog, signals_blocked -from pymmcore_widgets._device_property_table import ICONS +from pymmcore_widgets._icons import ICONS from ._base_page import ConfigWizardPage from ._dev_setup_dialog import DeviceSetupDialog diff --git a/src/pymmcore_widgets/views/__init__.py b/src/pymmcore_widgets/views/__init__.py new file mode 100644 index 000000000..0f6b841ea --- /dev/null +++ b/src/pymmcore_widgets/views/__init__.py @@ -0,0 +1,5 @@ +"""View-related Widgets.""" + +from ._image_widget import ImagePreview + +__all__ = ["ImagePreview"] diff --git a/src/pymmcore_widgets/_image_widget.py b/src/pymmcore_widgets/views/_image_widget.py similarity index 100% rename from src/pymmcore_widgets/_image_widget.py rename to src/pymmcore_widgets/views/_image_widget.py diff --git a/src/pymmcore_widgets/_stack_viewer/__init__.py b/src/pymmcore_widgets/views/_stack_viewer/__init__.py similarity index 100% rename from src/pymmcore_widgets/_stack_viewer/__init__.py rename to src/pymmcore_widgets/views/_stack_viewer/__init__.py diff --git a/src/pymmcore_widgets/_stack_viewer/_channel_row.py b/src/pymmcore_widgets/views/_stack_viewer/_channel_row.py similarity index 100% rename from src/pymmcore_widgets/_stack_viewer/_channel_row.py rename to src/pymmcore_widgets/views/_stack_viewer/_channel_row.py diff --git a/src/pymmcore_widgets/_stack_viewer/_datastore.py b/src/pymmcore_widgets/views/_stack_viewer/_datastore.py similarity index 100% rename from src/pymmcore_widgets/_stack_viewer/_datastore.py rename to src/pymmcore_widgets/views/_stack_viewer/_datastore.py diff --git a/src/pymmcore_widgets/_stack_viewer/_labeled_slider.py b/src/pymmcore_widgets/views/_stack_viewer/_labeled_slider.py similarity index 100% rename from src/pymmcore_widgets/_stack_viewer/_labeled_slider.py rename to src/pymmcore_widgets/views/_stack_viewer/_labeled_slider.py diff --git a/src/pymmcore_widgets/_stack_viewer/_save_button.py b/src/pymmcore_widgets/views/_stack_viewer/_save_button.py similarity index 100% rename from src/pymmcore_widgets/_stack_viewer/_save_button.py rename to src/pymmcore_widgets/views/_stack_viewer/_save_button.py diff --git a/src/pymmcore_widgets/_stack_viewer/_stack_viewer.py b/src/pymmcore_widgets/views/_stack_viewer/_stack_viewer.py similarity index 100% rename from src/pymmcore_widgets/_stack_viewer/_stack_viewer.py rename to src/pymmcore_widgets/views/_stack_viewer/_stack_viewer.py diff --git a/tests/test_camera_roi_widget.py b/tests/test_camera_roi_widget.py index 1b0282cb5..7e32a2152 100644 --- a/tests/test_camera_roi_widget.py +++ b/tests/test_camera_roi_widget.py @@ -5,7 +5,7 @@ from pymmcore_plus import CMMCorePlus -from pymmcore_widgets._camera_roi_widget import CameraRoiWidget +from pymmcore_widgets.control._camera_roi_widget import CameraRoiWidget if TYPE_CHECKING: from pytestqt.qtbot import QtBot diff --git a/tests/test_channel_widget.py b/tests/test_channel_widget.py index 655fa76d4..5460f64da 100644 --- a/tests/test_channel_widget.py +++ b/tests/test_channel_widget.py @@ -4,9 +4,9 @@ from qtpy.QtWidgets import QComboBox -from pymmcore_widgets._channel_widget import ChannelWidget -from pymmcore_widgets._presets_widget import PresetsWidget from pymmcore_widgets._util import block_core +from pymmcore_widgets.control._channel_widget import ChannelWidget +from pymmcore_widgets.control._presets_widget import PresetsWidget if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/tests/test_core_state.py b/tests/test_core_state.py index 5abddb462..58db78e9a 100644 --- a/tests/test_core_state.py +++ b/tests/test_core_state.py @@ -18,7 +18,6 @@ pmmw.ChannelWidget: {}, pmmw.ConfigurationWidget: {}, pmmw.DefaultCameraExposureWidget: {}, - pmmw.DeviceWidget: {"device_label": "Camera"}, pmmw.ExposureWidget: {}, pmmw.GridPlanWidget: {}, pmmw.GroupPresetTableWidget: {}, @@ -35,7 +34,6 @@ pmmw.ShuttersWidget: {"shutter_device": "Shutter"}, pmmw.SnapButton: {}, pmmw.StageWidget: {"device": "XY"}, - pmmw.StateDeviceWidget: {"device_label": "Objective"}, pmmw.TimePlanWidget: {}, pmmw.ZPlanWidget: {}, pmmw.PositionTable: {}, diff --git a/tests/test_datastore.py b/tests/test_datastore.py index a31df07b7..e2465ea76 100644 --- a/tests/test_datastore.py +++ b/tests/test_datastore.py @@ -1,7 +1,7 @@ from pymmcore_plus import CMMCorePlus from useq import MDAEvent, MDASequence -from pymmcore_widgets._stack_viewer._datastore import QOMEZarrDatastore +from pymmcore_widgets.views._stack_viewer._datastore import QOMEZarrDatastore sequence = MDASequence( channels=[{"config": "DAPI", "exposure": 10}], diff --git a/tests/test_device_widget.py b/tests/test_device_widget.py index d8483fcb4..364e83f4c 100644 --- a/tests/test_device_widget.py +++ b/tests/test_device_widget.py @@ -2,15 +2,17 @@ from typing import TYPE_CHECKING +import pytest from pymmcore_plus import CMMCorePlus, DeviceType -from pymmcore_widgets import DeviceWidget, StateDeviceWidget - if TYPE_CHECKING: from pytestqt.qtbot import QtBot -def test_state_device_widget(qtbot: QtBot, global_mmcore: CMMCorePlus): +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +def test_state_device_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: + from pymmcore_widgets import DeviceWidget, StateDeviceWidget + for label in global_mmcore.getLoadedDevicesOfType(DeviceType.StateDevice): wdg: StateDeviceWidget = DeviceWidget.for_device(label) qtbot.addWidget(wdg) diff --git a/tests/test_group_preset_widget.py b/tests/test_group_preset_widget.py index 3d6b625cb..eefcbdd1e 100644 --- a/tests/test_group_preset_widget.py +++ b/tests/test_group_preset_widget.py @@ -11,11 +11,11 @@ from qtpy.QtWidgets import QFileDialog from pymmcore_widgets import PresetsWidget -from pymmcore_widgets._group_preset_widget._add_group_widget import AddGroupWidget -from pymmcore_widgets._group_preset_widget._add_preset_widget import AddPresetWidget -from pymmcore_widgets._group_preset_widget._edit_group_widget import EditGroupWidget -from pymmcore_widgets._group_preset_widget._edit_preset_widget import EditPresetWidget -from pymmcore_widgets._group_preset_widget._group_preset_table_widget import ( +from pymmcore_widgets.config_presets._group_preset_widget import ( + AddGroupWidget, + AddPresetWidget, + EditGroupWidget, + EditPresetWidget, GroupPresetTableWidget, ) diff --git a/tests/test_live_button.py b/tests/test_live_button.py index 06891ffbf..d8263ca73 100644 --- a/tests/test_live_button.py +++ b/tests/test_live_button.py @@ -4,7 +4,7 @@ from qtpy.QtCore import QSize -from pymmcore_widgets._live_button_widget import LiveButton +from pymmcore_widgets.control._live_button_widget import LiveButton if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/tests/test_load_system_cfg_widget.py b/tests/test_load_system_cfg_widget.py index 58ad910b0..8fafaa3eb 100644 --- a/tests/test_load_system_cfg_widget.py +++ b/tests/test_load_system_cfg_widget.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -from pymmcore_widgets._load_system_cfg_widget import ConfigurationWidget +from pymmcore_widgets.control._load_system_cfg_widget import ConfigurationWidget if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/tests/test_objective_pixel_config_widget.py b/tests/test_objective_pixel_config_widget.py index e248dcc13..b573e07c4 100644 --- a/tests/test_objective_pixel_config_widget.py +++ b/tests/test_objective_pixel_config_widget.py @@ -4,7 +4,7 @@ from pymmcore_plus import CMMCorePlus -from pymmcore_widgets._objectives_pixel_configuration_widget import ( +from pymmcore_widgets.config_presets._objectives_pixel_configuration_widget import ( ObjectivesPixelConfigurationWidget, PixelSizeTable, ) diff --git a/tests/test_objective_widget.py b/tests/test_objective_widget.py index 21aca25cb..dd7b82a9e 100644 --- a/tests/test_objective_widget.py +++ b/tests/test_objective_widget.py @@ -6,8 +6,8 @@ import pytest from qtpy.QtWidgets import QDialog -from pymmcore_widgets._objective_widget import ObjectivesWidget from pymmcore_widgets._util import ComboMessageBox +from pymmcore_widgets.control._objective_widget import ObjectivesWidget if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/tests/test_pixel_config_widget.py b/tests/test_pixel_config_widget.py index 03705ff28..8581990c3 100644 --- a/tests/test_pixel_config_widget.py +++ b/tests/test_pixel_config_widget.py @@ -7,7 +7,7 @@ from pymmcore_plus.model import PixelSizePreset, Setting from qtpy.QtCore import Qt -from pymmcore_widgets._pixel_configuration_widget import ( +from pymmcore_widgets.config_presets._pixel_configuration_widget import ( ID, NEW, PixelConfigurationWidget, diff --git a/tests/test_presets_widget.py b/tests/test_presets_widget.py index ef44b5801..1499bc0c3 100644 --- a/tests/test_presets_widget.py +++ b/tests/test_presets_widget.py @@ -4,7 +4,7 @@ import pytest -from pymmcore_widgets._presets_widget import PresetsWidget +from pymmcore_widgets.control._presets_widget import PresetsWidget if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/tests/test_shutter_widget.py b/tests/test_shutter_widget.py index 54176a21a..e6f3dfac7 100644 --- a/tests/test_shutter_widget.py +++ b/tests/test_shutter_widget.py @@ -5,7 +5,7 @@ import pytest from pymmcore_plus import CMMCorePlus -from pymmcore_widgets._shutter_widget import ShuttersWidget +from pymmcore_widgets.control._shutter_widget import ShuttersWidget if TYPE_CHECKING: from pytestqt.qtbot import QtBot diff --git a/tests/test_snap_button_widget.py b/tests/test_snap_button_widget.py index ead9b7364..9e0ad72e1 100644 --- a/tests/test_snap_button_widget.py +++ b/tests/test_snap_button_widget.py @@ -4,7 +4,7 @@ from qtpy.QtCore import QSize -from pymmcore_widgets._snap_button_widget import SnapButton +from pymmcore_widgets.control._snap_button_widget import SnapButton if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/tests/test_stack_viewer.py b/tests/test_stack_viewer.py index 672fe422d..6f7bc4c66 100644 --- a/tests/test_stack_viewer.py +++ b/tests/test_stack_viewer.py @@ -12,8 +12,8 @@ from vispy.app.canvas import MouseEvent from vispy.scene.events import SceneMouseEvent -from pymmcore_widgets._stack_viewer import CMAPS from pymmcore_widgets.experimental import StackViewer +from pymmcore_widgets.views._stack_viewer import CMAPS if TYPE_CHECKING: from pytestqt.qtbot import QtBot diff --git a/tests/test_stage_widget.py b/tests/test_stage_widget.py index 4b5104d47..fa54f9f0a 100644 --- a/tests/test_stage_widget.py +++ b/tests/test_stage_widget.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -from pymmcore_widgets._stage_widget import StageWidget +from pymmcore_widgets.control._stage_widget import StageWidget if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus From e1a100d4da9ea4abc54819ec9b66e48e94c5dafa Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 4 Oct 2024 14:33:10 -0400 Subject: [PATCH 27/38] build: pin useq-schema to 0.5.0 (#367) pin useq --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c6473c0c5..c162201bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dependencies = [ 'pymmcore-plus[cli] >=0.11.0', 'qtpy >=2.0', 'superqt[quantity] >=0.5.3', - 'useq-schema @ git+https://github.com/pymmcore-plus/useq-schema.git', # temporary until new useq release + 'useq-schema >=0.5.0', ] [tool.hatch.metadata] From 18e9c30a8277510607a242028e2a8b4130e5f2d6 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 4 Oct 2024 14:40:40 -0400 Subject: [PATCH 28/38] chore: changelog v0.8.0 --- CHANGELOG.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 003757553..a336e8cfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # Changelog +## [v0.8.0](https://github.com/pymmcore-plus/pymmcore-widgets/tree/v0.8.0) (2024-10-04) + +[Full Changelog](https://github.com/pymmcore-plus/pymmcore-widgets/compare/v0.7.2...v0.8.0) + +**Implemented enhancements:** + +- feat: add HCSWizard to MDAWIdget [\#362](https://github.com/pymmcore-plus/pymmcore-widgets/pull/362) ([fdrgsp](https://github.com/fdrgsp)) +- feat: add High Content Screening wizard [\#360](https://github.com/pymmcore-plus/pymmcore-widgets/pull/360) ([fdrgsp](https://github.com/fdrgsp)) +- feat: reload prior config file on HCW rejection [\#359](https://github.com/pymmcore-plus/pymmcore-widgets/pull/359) ([gselzer](https://github.com/gselzer)) +- feat: plate navigator for HCS calibration testing [\#356](https://github.com/pymmcore-plus/pymmcore-widgets/pull/356) ([fdrgsp](https://github.com/fdrgsp)) +- feat: plate calibration widget [\#355](https://github.com/pymmcore-plus/pymmcore-widgets/pull/355) ([tlambert03](https://github.com/tlambert03)) +- feat: reusable single-well calibration widget for plate calibration widget [\#353](https://github.com/pymmcore-plus/pymmcore-widgets/pull/353) ([fdrgsp](https://github.com/fdrgsp)) +- feat: Refactor GridPlanWidget [\#351](https://github.com/pymmcore-plus/pymmcore-widgets/pull/351) ([gselzer](https://github.com/gselzer)) +- feat: add restrict well area [\#319](https://github.com/pymmcore-plus/pymmcore-widgets/pull/319) ([fdrgsp](https://github.com/fdrgsp)) +- feat: add useq.WellPlanPlan widget with well selection [\#318](https://github.com/pymmcore-plus/pymmcore-widgets/pull/318) ([tlambert03](https://github.com/tlambert03)) +- feat: add overlap checkbox [\#317](https://github.com/pymmcore-plus/pymmcore-widgets/pull/317) ([fdrgsp](https://github.com/fdrgsp)) +- feat: add minimal Points plan view [\#316](https://github.com/pymmcore-plus/pymmcore-widgets/pull/316) ([tlambert03](https://github.com/tlambert03)) +- feat: Points plan selector [\#315](https://github.com/pymmcore-plus/pymmcore-widgets/pull/315) ([tlambert03](https://github.com/tlambert03)) +- feat: multi point plan useq widgets [\#314](https://github.com/pymmcore-plus/pymmcore-widgets/pull/314) ([tlambert03](https://github.com/tlambert03)) +- feat: add select all for hub devices [\#310](https://github.com/pymmcore-plus/pymmcore-widgets/pull/310) ([tlambert03](https://github.com/tlambert03)) + +**Fixed bugs:** + +- fix: fix splitting logic and deduplicate code in Groups Presets Widgets [\#365](https://github.com/pymmcore-plus/pymmcore-widgets/pull/365) ([tlambert03](https://github.com/tlambert03)) +- fix: disable Autofocus checkbox when using HCSWizard [\#364](https://github.com/pymmcore-plus/pymmcore-widgets/pull/364) ([fdrgsp](https://github.com/fdrgsp)) +- fix: enable ct axis order [\#361](https://github.com/pymmcore-plus/pymmcore-widgets/pull/361) ([fdrgsp](https://github.com/fdrgsp)) +- fix: fix valueChanged signals on PropertyWidget [\#352](https://github.com/pymmcore-plus/pymmcore-widgets/pull/352) ([tlambert03](https://github.com/tlambert03)) +- fix: Only allow YAML save/load when YAML available [\#347](https://github.com/pymmcore-plus/pymmcore-widgets/pull/347) ([gselzer](https://github.com/gselzer)) +- fix: Align spin boxes and labels in GridPlan [\#345](https://github.com/pymmcore-plus/pymmcore-widgets/pull/345) ([gselzer](https://github.com/gselzer)) +- fix: update the GroupPresetTableWidget policy [\#330](https://github.com/pymmcore-plus/pymmcore-widgets/pull/330) ([fdrgsp](https://github.com/fdrgsp)) +- fix: make name editable EditGroupWidget [\#328](https://github.com/pymmcore-plus/pymmcore-widgets/pull/328) ([fdrgsp](https://github.com/fdrgsp)) +- fix: WellPlateWidget initial drawing [\#327](https://github.com/pymmcore-plus/pymmcore-widgets/pull/327) ([fdrgsp](https://github.com/fdrgsp)) +- fix: fix bug in config wizard where core state bleeds into model [\#309](https://github.com/pymmcore-plus/pymmcore-widgets/pull/309) ([tlambert03](https://github.com/tlambert03)) + +**Merged pull requests:** + +- build: pin useq-schema to 0.5.0 [\#367](https://github.com/pymmcore-plus/pymmcore-widgets/pull/367) ([tlambert03](https://github.com/tlambert03)) +- refactor: full repo reorganization [\#366](https://github.com/pymmcore-plus/pymmcore-widgets/pull/366) ([tlambert03](https://github.com/tlambert03)) +- ci\(pre-commit.ci\): autoupdate [\#358](https://github.com/pymmcore-plus/pymmcore-widgets/pull/358) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) +- ci\(pre-commit.ci\): autoupdate [\#357](https://github.com/pymmcore-plus/pymmcore-widgets/pull/357) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) +- refactor: more grid plan cleanup [\#354](https://github.com/pymmcore-plus/pymmcore-widgets/pull/354) ([tlambert03](https://github.com/tlambert03)) +- refactor: split run mda in mda widget [\#350](https://github.com/pymmcore-plus/pymmcore-widgets/pull/350) ([wl-stepp](https://github.com/wl-stepp)) +- style: clarify save/load buttons in MDAWidget [\#346](https://github.com/pymmcore-plus/pymmcore-widgets/pull/346) ([gselzer](https://github.com/gselzer)) +- style: unfill radio buttions in GridPlanWidget [\#344](https://github.com/pymmcore-plus/pymmcore-widgets/pull/344) ([gselzer](https://github.com/gselzer)) +- style: Manually compute sizeHint\(\) [\#343](https://github.com/pymmcore-plus/pymmcore-widgets/pull/343) ([gselzer](https://github.com/gselzer)) +- style: fix pixel affine table [\#341](https://github.com/pymmcore-plus/pymmcore-widgets/pull/341) ([tlambert03](https://github.com/tlambert03)) +- refactor: refactor stage widget [\#334](https://github.com/pymmcore-plus/pymmcore-widgets/pull/334) ([tlambert03](https://github.com/tlambert03)) +- refactor: remove old MDA widget [\#313](https://github.com/pymmcore-plus/pymmcore-widgets/pull/313) ([tlambert03](https://github.com/tlambert03)) +- refactor: pydantic2 syntax [\#311](https://github.com/pymmcore-plus/pymmcore-widgets/pull/311) ([tlambert03](https://github.com/tlambert03)) +- ci\(pre-commit.ci\): autoupdate [\#306](https://github.com/pymmcore-plus/pymmcore-widgets/pull/306) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) + ## [v0.7.2](https://github.com/pymmcore-plus/pymmcore-widgets/tree/v0.7.2) (2024-06-13) [Full Changelog](https://github.com/pymmcore-plus/pymmcore-widgets/compare/v0.7.1...v0.7.2) From ae4a3ddbdefdb2abb04d870e01effa5aed00b301 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 11 Oct 2024 15:19:32 -0400 Subject: [PATCH 29/38] ci(pre-commit.ci): autoupdate (#369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/crate-ci/typos: typos-dict-v0.11.27 → v1.26.0](https://github.com/crate-ci/typos/compare/typos-dict-v0.11.27...v1.26.0) - [github.com/astral-sh/ruff-pre-commit: v0.6.3 → v0.6.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.3...v0.6.9) - [github.com/abravalheri/validate-pyproject: v0.19 → v0.20.2](https://github.com/abravalheri/validate-pyproject/compare/v0.19...v0.20.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4147be169..6d88f1a59 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,19 +5,19 @@ ci: repos: - repo: https://github.com/crate-ci/typos - rev: typos-dict-v0.11.27 + rev: v1.26.0 hooks: - id: typos - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.3 + rev: v0.6.9 hooks: - id: ruff args: [--fix, --unsafe-fixes] - id: ruff-format - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.19 + rev: v0.20.2 hooks: - id: validate-pyproject From a585acb9598ad9b2aaed162c26c7e55c7b52c8fd Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 11 Oct 2024 16:03:39 -0500 Subject: [PATCH 30/38] chore: fix typing for useq 0.5 (#371) * chore: fix typing for useq 0.5 * pin useq in pre-commit * remove breakpoint --- .pre-commit-config.yaml | 2 +- src/pymmcore_widgets/_util.py | 2 ++ src/pymmcore_widgets/mda/_core_positions.py | 4 ++-- src/pymmcore_widgets/useq_widgets/_mda_sequence.py | 6 +++--- .../useq_widgets/_well_plate_widget.py | 4 ++-- .../points_plans/_points_plan_selector.py | 5 +++-- .../points_plans/_random_points_widget.py | 14 +++++++------- 7 files changed, 20 insertions(+), 17 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6d88f1a59..b5f6d794e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,4 +28,4 @@ repos: files: "^src/" additional_dependencies: - pymmcore-plus >=0.11.0 - - useq-schema >=0.4.7 + - useq-schema >=0.5.0 diff --git a/src/pymmcore_widgets/_util.py b/src/pymmcore_widgets/_util.py index b312b90d0..1055dfee7 100644 --- a/src/pymmcore_widgets/_util.py +++ b/src/pymmcore_widgets/_util.py @@ -108,6 +108,8 @@ def cast_grid_plan( return None if isinstance(grid, dict): _grid = useq.MDASequence(grid_plan=grid).grid_plan + if isinstance(_grid, useq.RelativePosition): # pragma: no cover + raise ValueError("Grid plan cannot be a single Relative position.") return None if isinstance(_grid, useq.RandomPoints) else _grid return grid diff --git a/src/pymmcore_widgets/mda/_core_positions.py b/src/pymmcore_widgets/mda/_core_positions.py index 2d423b321..f89d7b5eb 100644 --- a/src/pymmcore_widgets/mda/_core_positions.py +++ b/src/pymmcore_widgets/mda/_core_positions.py @@ -121,13 +121,13 @@ def __init__( def value( self, exclude_unchecked: bool = True, exclude_hidden_cols: bool = True - ) -> tuple[Position, ...] | WellPlatePlan: + ) -> Sequence[Position]: """Return the current state of the positions table.""" if self._plate_plan is not None: return self._plate_plan return super().value(exclude_unchecked, exclude_hidden_cols) - def setValue(self, value: Sequence[Position] | WellPlatePlan) -> None: + def setValue(self, value: Sequence[Position]) -> None: # type: ignore [override] """Set the value of the positions table.""" if isinstance(value, WellPlatePlan): self._plate_plan = value diff --git a/src/pymmcore_widgets/useq_widgets/_mda_sequence.py b/src/pymmcore_widgets/useq_widgets/_mda_sequence.py index ffb00fee7..0205b8f7a 100644 --- a/src/pymmcore_widgets/useq_widgets/_mda_sequence.py +++ b/src/pymmcore_widgets/useq_widgets/_mda_sequence.py @@ -3,7 +3,7 @@ from importlib.util import find_spec from itertools import permutations from pathlib import Path -from typing import cast +from typing import Sequence, cast import useq from qtpy.QtCore import Qt, Signal @@ -568,8 +568,8 @@ def _simplify_af_offsets(self, seq: useq.MDASequence) -> dict: return {"autofocus_plan": af_plan, "stage_positions": stage_positions} def _update_af_axes( - self, positions: tuple[useq.Position, ...] - ) -> tuple[useq.Position, ...]: + self, positions: Sequence[useq.Position] + ) -> Sequence[useq.Position]: """Add the autofocus axes to each subsequence.""" new_pos = [] for pos in positions: diff --git a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py index 31c18d706..6a52bb96b 100644 --- a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py +++ b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterable, Mapping +from typing import TYPE_CHECKING, Iterable, Mapping, cast import numpy as np import useq @@ -438,7 +438,7 @@ def drawPlate(self, plan: useq.WellPlate | useq.WellPlatePlan) -> None: indices = plan.all_well_indices.reshape(-1, 2) for idx, pos in zip(indices, plan.all_well_positions): # invert y-axis for screen coordinates - screen_x, screen_y = pos.x, -pos.y + screen_x, screen_y = pos.x, -cast(float, pos.y) rect = well_rect.translated(screen_x, screen_y) if item := add_item(rect, pen): item.setData(DATA_POSITION, pos) diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py index 104744fb0..41072ab0b 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py @@ -200,11 +200,12 @@ def _on_radiobutton_toggled(self, btn: QRadioButton, checked: bool) -> None: wdg.setEnabled(checked) if checked: self._active_plan_widget = wdg - self._active_plan_type = { + d: dict[QRadioButton, type[RelativePointPlan]] = { self.single_radio_btn: useq.RelativePosition, self.random_radio_btn: useq.RandomPoints, self.grid_radio_btn: useq.GridRowsColumns, - }[btn] + } + self._active_plan_type = d[btn] self._on_value_changed() def _on_value_changed(self) -> None: diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py index ee635fb49..a5e61d366 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py @@ -15,7 +15,7 @@ QVBoxLayout, QWidget, ) -from useq import RandomPoints, Shape, TraversalOrder +from useq import RandomPoints, RelativePosition, Shape, TraversalOrder class RandomPointWidget(QWidget): @@ -28,7 +28,7 @@ def __init__(self, parent: QWidget | None = None) -> None: # NON-GUI attributes - self._start_at: int = 0 + self._start_at: int | RelativePosition = 0 # setting a random seed for point generation reproducibility self.random_seed: int = self._new_seed() @@ -114,12 +114,12 @@ def fov_size(self, size: tuple[float | None, float | None]) -> None: # not in the gui for now... @property - def start_at(self) -> int: + def start_at(self) -> RelativePosition | int: """Return the start_at value.""" return self._start_at @start_at.setter - def start_at(self, value: int) -> None: + def start_at(self, value: RelativePosition | int) -> None: """Set the start_at value.""" self._start_at = value self._on_value_changed() @@ -162,9 +162,9 @@ def setValue(self, value: RandomPoints | Mapping) -> None: self.shape.setCurrentText(value.shape.value) self._fov_size = (value.fov_width, value.fov_height) self.allow_overlap.setChecked(value.allow_overlap) - self.start_at = value.start_at # type: ignore # until useq is released - if value.order is not None: # type: ignore # until useq is released - self.order.setCurrentText(value.order.value) # type: ignore # until useq is released + self.start_at = value.start_at + if value.order is not None: + self.order.setCurrentText(value.order.value) def reset(self) -> None: """Reset value to 1 point and 0 area.""" From d58003b33851e779c19e47706aa45429e23d147f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 12 Oct 2024 10:52:07 -0500 Subject: [PATCH 31/38] build: drop support for python 3.8, test 3.12 (#368) * chore: drop py3.8, support 3.13 * formatting * change matrix * change matrix * change pinning * switch py313 to windows * ignore failed to disconnect * fix pyside6 * no 3.13 yet * style(pre-commit.ci): auto fixes [...] * pin pyside * update readme * don't test stack viewer on pyside --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 28 ++++++++----------- .github/workflows/cron.yml | 7 ++--- README.md | 3 +- docs/getting_started.md | 20 +++++++++---- pyproject.toml | 23 ++++++++++----- src/pymmcore_widgets/_util.py | 8 ++++-- .../_group_preset_widget/_cfg_table.py | 5 +++- .../_pixel_configuration_widget.py | 5 +++- .../_device_property_table.py | 5 +++- .../device_properties/_properties_widget.py | 3 +- .../hcs/_plate_calibration_widget.py | 5 +++- src/pymmcore_widgets/hcs/_util.py | 5 +++- .../hcs/_well_calibration_widget.py | 5 +++- .../hcwizard/_dev_setup_dialog.py | 4 ++- .../hcwizard/_peripheral_setup_dialog.py | 4 ++- .../hcwizard/_simple_prop_table.py | 5 +++- src/pymmcore_widgets/mda/_core_positions.py | 4 ++- .../useq_widgets/_channels.py | 5 +++- .../useq_widgets/_column_info.py | 3 +- .../useq_widgets/_data_table.py | 5 ++-- .../useq_widgets/_mda_sequence.py | 5 +++- .../useq_widgets/_positions.py | 5 +++- .../useq_widgets/_well_plate_widget.py | 4 ++- .../points_plans/_grid_row_column_widget.py | 5 +++- .../points_plans/_random_points_widget.py | 5 +++- .../views/_stack_viewer/_stack_viewer.py | 8 ++++++ tests/test_presets_widget.py | 2 +- tests/test_stack_viewer.py | 10 ++++--- 28 files changed, 135 insertions(+), 61 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ce762d5c..3d1b9dc2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,30 +31,25 @@ jobs: fail-fast: false matrix: platform: [macos-13, windows-latest] - python-version: ["3.8", "3.11"] - backend: [pyside2, pyqt5] - include: - - platform: macos-13 - python-version: "3.10" - backend: pyside6 - - platform: macos-13 - python-version: "3.11" - backend: pyqt6 + python-version: ["3.10", "3.12"] + backend: [pyside6, pyqt6] + exclude: - platform: windows-latest python-version: "3.10" backend: pyside6 - - platform: windows-latest - python-version: "3.11" - backend: pyqt6 - - platform: windows-latest + include: + - platform: macos-13 python-version: "3.9" backend: pyside2 - platform: windows-latest python-version: "3.9" - backend: pyqt5 - exclude: - - python-version: "3.11" backend: pyside2 + - platform: windows-latest + python-version: "3.11" + backend: pyqt5 + # - platform: windows-latest + # python-version: "3.13" + # backend: pyqt6 steps: - uses: actions/checkout@v4 @@ -63,6 +58,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install dependencies run: | diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index d0364687a..5bcc6e01f 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -5,18 +5,17 @@ name: --pre Test on: schedule: - - cron: '0 */12 * * *' # every 12 hours + - cron: "0 */12 * * *" # every 12 hours workflow_dispatch: jobs: - test: name: ${{ matrix.platform }} (${{ matrix.python-version }}) runs-on: ${{ matrix.platform }} strategy: fail-fast: false matrix: - python-version: ['3.8', '3.9', '3.10'] + python-version: ["3.11"] platform: [macos-13, windows-latest] backend: [pyside2, pyqt5] @@ -55,7 +54,7 @@ jobs: PLATFORM: ${{ matrix.platform }} PYTHON: ${{ matrix.python }} RUN_ID: ${{ github.run_id }} - TITLE: '[test-bot] pip install --pre is failing' + TITLE: "[test-bot] pip install --pre is failing" with: filename: .github/TEST_FAIL_TEMPLATE.md update_existing: true diff --git a/README.md b/README.md index e80e4a427..4a70d97c3 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,6 @@ It forms the basis of [`napari-micromanager`](https://github.com/pymmcore-plus/n See complete list of available widgets in the [documentation](https://pymmcore-plus.github.io/pymmcore-widgets/#widgets) - ## Installation ```sh @@ -28,5 +27,5 @@ pip install pymmcore-widgets # you must install one yourself, for example: pip install PyQt5 -# package is tested against PyQt5, PyQt6, PySide2, and PySide6 +# package is tested against PyQt5, PyQt6, PySide2, and PySide6(==6.7) ``` diff --git a/docs/getting_started.md b/docs/getting_started.md index 495ed6b84..6cef83567 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -12,7 +12,18 @@ pip install pymmcore-widgets ### Installing PyQt or PySide -Since [pymmcore-widgets](./index.md) relies on either the [PyQt](https://riverbankcomputing.com/software/pyqt/) or [PySide](https://www.qt.io/qt-for-python) libraries, you also **need** to install one of these packages. You can use any of the available versions of these libraries: [PyQt5](https://pypi.org/project/PyQt5/), [PyQt6](https://pypi.org/project/PyQt6/), [PySide2](https://pypi.org/project/PySide2/) or [PySide6](https://pypi.org/project/PySide6/). For example, to install [PyQt6](https://riverbankcomputing.com/software/pyqt/download), you can use: +Since [pymmcore-widgets](./index.md) relies on either the +[PyQt](https://riverbankcomputing.com/software/pyqt/) or +[PySide](https://www.qt.io/qt-for-python) libraries, you also **need** to +install one of these packages. You can use any of the available versions of +these libraries: [PyQt5](https://pypi.org/project/PyQt5/), +[PyQt6](https://pypi.org/project/PyQt6/), +[PySide2](https://pypi.org/project/PySide2/) or +[PySide6](https://pypi.org/project/PySide6/). We strongly recommend using PyQt6 +if possible. If you must use a specific backend version and run into problems, +please open an issue + +For example, to install [PyQt6](https://riverbankcomputing.com/software/pyqt/download), you can use: ```sh pip install PyQt6 @@ -21,10 +32,9 @@ pip install PyQt6 !!! Note Widgets are tested on: - * `macOS & Windows` - * `Python 3.8, 3.9 3.10 & 3.11` - * `PyQt5 & PyQt6` - * `PySide2 & PySide6` + * macOS & Windows + * Python 3.9 and above + * PyQt5, PyQt6, PySide2 & PySide6(==6.7) ### Installing Micro-Manager diff --git a/pyproject.toml b/pyproject.toml index c162201bf..5f9648e0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ sources = ["src"] name = "pymmcore-widgets" description = "A set of Qt-based widgets onto the pymmcore-plus model" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.9" license = { text = "BSD 3-Clause License" } authors = [ { email = "federico.gasparoli@gmail.com", name = "Federico Gasparoli" }, @@ -34,10 +34,11 @@ classifiers = [ "Intended Audience :: Science/Research", "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python", "Topic :: Software Development :: Widget Sets", "Topic :: System :: Hardware :: Hardware Drivers", @@ -60,11 +61,19 @@ allow-direct-references = true # extras # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] -test = ["pytest>=6.0", "pytest-cov", "pytest-qt", "PyYAML", "vispy", "cmap", "zarr"] +test = [ + "pytest>=6.0", + "pytest-cov", + "pytest-qt", + "PyYAML", + "vispy", + "cmap", + "zarr", +] pyqt5 = ["PyQt5"] pyside2 = ["PySide2"] pyqt6 = ["PyQt6"] -pyside6 = ["PySide6<6.5"] +pyside6 = ["PySide6==6.7.3"] # pretty hard to find a good match here... image = ["vispy"] dev = [ @@ -97,8 +106,9 @@ Documentation = "https://pymmcore-plus.github.io/pymmcore-widgets" # https://beta.ruff.rs/docs/rules/ [tool.ruff] line-length = 88 -target-version = "py38" +target-version = "py39" src = ["src", "tests"] + [tool.ruff.lint] pydocstyle = { convention = "numpy" } select = [ @@ -115,7 +125,6 @@ select = [ "RUF", # ruff-specific rules "TID", # tidy "TCH", # typecheck - # "SLF", # private-access ] ignore = [ "D100", # Missing docstring in public module @@ -135,6 +144,7 @@ testpaths = ["tests"] filterwarnings = [ "error", "ignore:distutils Version classes are deprecated", + "ignore:Failed to disconnect:RuntimeWarning:", # warning, but not error, that will show up on useq<0.3.3 ] @@ -178,4 +188,3 @@ ignore = [ [tool.typos.default] extend-ignore-identifiers-re = ["(?i)nd2?.*", "(?i)ome", "FO(Vs?)?"] - diff --git a/src/pymmcore_widgets/_util.py b/src/pymmcore_widgets/_util.py index 1055dfee7..4436e0efc 100644 --- a/src/pymmcore_widgets/_util.py +++ b/src/pymmcore_widgets/_util.py @@ -2,7 +2,7 @@ import re from pathlib import Path -from typing import Any, ContextManager, Sequence +from typing import TYPE_CHECKING, Any import useq from psygnal import SignalInstance @@ -21,6 +21,10 @@ ) from superqt.utils import signals_blocked +if TYPE_CHECKING: + from collections.abc import Sequence + from contextlib import AbstractContextManager + class ComboMessageBox(QDialog): """Dialog that presents a combo box of `items`.""" @@ -91,7 +95,7 @@ def guess_objective_or_prompt( return None -def block_core(obj: Any) -> ContextManager: +def block_core(obj: Any) -> AbstractContextManager: """Block core signals.""" if isinstance(obj, QObject): return signals_blocked(obj) # type: ignore [no-any-return] diff --git a/src/pymmcore_widgets/config_presets/_group_preset_widget/_cfg_table.py b/src/pymmcore_widgets/config_presets/_group_preset_widget/_cfg_table.py index 5e8672b60..39a3316d0 100644 --- a/src/pymmcore_widgets/config_presets/_group_preset_widget/_cfg_table.py +++ b/src/pymmcore_widgets/config_presets/_group_preset_widget/_cfg_table.py @@ -1,12 +1,15 @@ from __future__ import annotations -from typing import Any, Sequence, cast +from typing import TYPE_CHECKING, Any, cast from qtpy.QtCore import Qt from qtpy.QtWidgets import QTableWidget, QTableWidgetItem from pymmcore_widgets.device_properties._property_widget import PropertyWidget +if TYPE_CHECKING: + from collections.abc import Sequence + DEV_PROP_ROLE = Qt.ItemDataRole.UserRole + 1 diff --git a/src/pymmcore_widgets/config_presets/_pixel_configuration_widget.py b/src/pymmcore_widgets/config_presets/_pixel_configuration_widget.py index cf85559ad..b628045c6 100644 --- a/src/pymmcore_widgets/config_presets/_pixel_configuration_widget.py +++ b/src/pymmcore_widgets/config_presets/_pixel_configuration_widget.py @@ -3,7 +3,7 @@ import itertools import warnings from collections import Counter -from typing import Any, Sequence, cast +from typing import TYPE_CHECKING, Any, cast from pymmcore_plus import CMMCorePlus, DeviceProperty from pymmcore_plus.model import PixelSizeGroup, PixelSizePreset, Setting @@ -35,6 +35,9 @@ from pymmcore_widgets.useq_widgets import DataTable, DataTableWidget from pymmcore_widgets.useq_widgets._column_info import FloatColumn, TextColumn +if TYPE_CHECKING: + from collections.abc import Sequence + FIXED = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) PX = "px" ID = "id" diff --git a/src/pymmcore_widgets/device_properties/_device_property_table.py b/src/pymmcore_widgets/device_properties/_device_property_table.py index aeffda804..e9b490897 100644 --- a/src/pymmcore_widgets/device_properties/_device_property_table.py +++ b/src/pymmcore_widgets/device_properties/_device_property_table.py @@ -1,7 +1,7 @@ from __future__ import annotations from logging import getLogger -from typing import Iterable, cast +from typing import TYPE_CHECKING, cast from pymmcore_plus import CMMCorePlus, DeviceProperty, DeviceType from qtpy.QtCore import Qt @@ -13,6 +13,9 @@ from ._property_widget import PropertyWidget +if TYPE_CHECKING: + from collections.abc import Iterable + logger = getLogger(__name__) diff --git a/src/pymmcore_widgets/device_properties/_properties_widget.py b/src/pymmcore_widgets/device_properties/_properties_widget.py index de3ed2854..215428f09 100644 --- a/src/pymmcore_widgets/device_properties/_properties_widget.py +++ b/src/pymmcore_widgets/device_properties/_properties_widget.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterable, cast +from typing import TYPE_CHECKING, cast from pymmcore_plus import CMMCorePlus from qtpy.QtWidgets import QGridLayout, QLabel, QWidget @@ -14,6 +14,7 @@ if TYPE_CHECKING: import re + from collections.abc import Iterable class PropertiesWidget(QWidget): diff --git a/src/pymmcore_widgets/hcs/_plate_calibration_widget.py b/src/pymmcore_widgets/hcs/_plate_calibration_widget.py index 651cb7264..711143b91 100644 --- a/src/pymmcore_widgets/hcs/_plate_calibration_widget.py +++ b/src/pymmcore_widgets/hcs/_plate_calibration_widget.py @@ -1,7 +1,7 @@ from __future__ import annotations from contextlib import suppress -from typing import Mapping +from typing import TYPE_CHECKING import numpy as np import useq @@ -28,6 +28,9 @@ ) from pymmcore_widgets.useq_widgets._well_plate_widget import WellPlateView +if TYPE_CHECKING: + from collections.abc import Mapping + class PlateCalibrationWidget(QWidget): """Widget to calibrate a well plate. diff --git a/src/pymmcore_widgets/hcs/_util.py b/src/pymmcore_widgets/hcs/_util.py index 4430c67f3..26868abc5 100644 --- a/src/pymmcore_widgets/hcs/_util.py +++ b/src/pymmcore_widgets/hcs/_util.py @@ -1,9 +1,12 @@ from __future__ import annotations -from typing import Iterable +from typing import TYPE_CHECKING import numpy as np +if TYPE_CHECKING: + from collections.abc import Iterable + def find_circle_center( coords: Iterable[tuple[float, float]], diff --git a/src/pymmcore_widgets/hcs/_well_calibration_widget.py b/src/pymmcore_widgets/hcs/_well_calibration_widget.py index ba8e65118..474788b9e 100644 --- a/src/pymmcore_widgets/hcs/_well_calibration_widget.py +++ b/src/pymmcore_widgets/hcs/_well_calibration_widget.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import Iterator, NamedTuple, cast +from typing import TYPE_CHECKING, NamedTuple, cast from fonticon_mdi6 import MDI6 from pymmcore_plus import CMMCorePlus @@ -23,6 +23,9 @@ from ._util import find_circle_center, find_rectangle_center +if TYPE_CHECKING: + from collections.abc import Iterator + COMBO_ROLE = Qt.ItemDataRole.UserRole + 1 ICON_PATH = Path(__file__).parent / "icons" ONE_CIRCLE = QIcon(str(ICON_PATH / "circle-center.svg")) diff --git a/src/pymmcore_widgets/hcwizard/_dev_setup_dialog.py b/src/pymmcore_widgets/hcwizard/_dev_setup_dialog.py index 84b74297f..375c6d7b5 100644 --- a/src/pymmcore_widgets/hcwizard/_dev_setup_dialog.py +++ b/src/pymmcore_widgets/hcwizard/_dev_setup_dialog.py @@ -85,7 +85,7 @@ import logging from contextlib import suppress -from typing import TYPE_CHECKING, Sequence +from typing import TYPE_CHECKING from pymmcore_plus import CMMCorePlus, Keyword from qtpy.QtCore import Qt @@ -104,6 +104,8 @@ from ._simple_prop_table import PropTable if TYPE_CHECKING: + from collections.abc import Sequence + from qtpy.QtGui import QFocusEvent logger = logging.getLogger(__name__) diff --git a/src/pymmcore_widgets/hcwizard/_peripheral_setup_dialog.py b/src/pymmcore_widgets/hcwizard/_peripheral_setup_dialog.py index a13bfc387..31e00a973 100644 --- a/src/pymmcore_widgets/hcwizard/_peripheral_setup_dialog.py +++ b/src/pymmcore_widgets/hcwizard/_peripheral_setup_dialog.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterable, cast +from typing import TYPE_CHECKING, cast from pymmcore_plus.model import Device, Microscope from qtpy.QtCore import QSize, Qt @@ -20,6 +20,8 @@ from ._dev_setup_dialog import DeviceSetupDialog if TYPE_CHECKING: + from collections.abc import Iterable + from pymmcore_plus import CMMCorePlus FLAGS = Qt.WindowType.MSWindowsFixedSizeDialogHint | Qt.WindowType.Sheet diff --git a/src/pymmcore_widgets/hcwizard/_simple_prop_table.py b/src/pymmcore_widgets/hcwizard/_simple_prop_table.py index a1abc5cbb..b30240190 100644 --- a/src/pymmcore_widgets/hcwizard/_simple_prop_table.py +++ b/src/pymmcore_widgets/hcwizard/_simple_prop_table.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Iterator, Sequence +from typing import TYPE_CHECKING from pymmcore_plus import CMMCorePlus, Keyword from qtpy.QtCore import Signal @@ -10,6 +10,9 @@ from pymmcore_widgets.device_properties._property_widget import PropertyWidget +if TYPE_CHECKING: + from collections.abc import Iterator, Sequence + class PortSelector(QComboBox): """Simple combobox that emits (device_name, library_name) when changed.""" diff --git a/src/pymmcore_widgets/mda/_core_positions.py b/src/pymmcore_widgets/mda/_core_positions.py index f89d7b5eb..b0e35b281 100644 --- a/src/pymmcore_widgets/mda/_core_positions.py +++ b/src/pymmcore_widgets/mda/_core_positions.py @@ -1,7 +1,7 @@ from __future__ import annotations from contextlib import suppress -from typing import TYPE_CHECKING, Any, Sequence +from typing import TYPE_CHECKING, Any from fonticon_mdi6 import MDI6 from pymmcore_plus import CMMCorePlus @@ -27,6 +27,8 @@ from pymmcore_widgets.useq_widgets._positions import AF_DEFAULT_TOOLTIP if TYPE_CHECKING: + from collections.abc import Sequence + from useq import Position UPDATE_POSITIONS = "Update Positions List" diff --git a/src/pymmcore_widgets/useq_widgets/_channels.py b/src/pymmcore_widgets/useq_widgets/_channels.py index 64c3a69bb..d2c304552 100644 --- a/src/pymmcore_widgets/useq_widgets/_channels.py +++ b/src/pymmcore_widgets/useq_widgets/_channels.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections import Counter -from typing import Any, Iterable, Mapping, Sequence +from typing import TYPE_CHECKING, Any import useq from pymmcore_plus import Keyword @@ -18,6 +18,9 @@ ) from ._data_table import DataTableWidget +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping, Sequence + NAMED_CONFIG = TextColumn(key="config", default=None, is_row_selector=True) DEFAULT_GROUP = Keyword.Channel diff --git a/src/pymmcore_widgets/useq_widgets/_column_info.py b/src/pymmcore_widgets/useq_widgets/_column_info.py index 70fbd0d89..81f2cf154 100644 --- a/src/pymmcore_widgets/useq_widgets/_column_info.py +++ b/src/pymmcore_widgets/useq_widgets/_column_info.py @@ -4,7 +4,7 @@ import re from dataclasses import dataclass from datetime import timedelta -from typing import TYPE_CHECKING, Callable, ClassVar, Generic, Iterable, TypeVar, cast +from typing import TYPE_CHECKING, Callable, ClassVar, Generic, TypeVar, cast import pint from qtpy.QtCore import Qt, Signal, SignalInstance @@ -25,6 +25,7 @@ from superqt.fonticon import icon if TYPE_CHECKING: + from collections.abc import Iterable from typing import Any diff --git a/src/pymmcore_widgets/useq_widgets/_data_table.py b/src/pymmcore_widgets/useq_widgets/_data_table.py index 4d87f46cc..7a853a7cb 100644 --- a/src/pymmcore_widgets/useq_widgets/_data_table.py +++ b/src/pymmcore_widgets/useq_widgets/_data_table.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar, Iterable, cast +from typing import TYPE_CHECKING, ClassVar, cast from fonticon_mdi6 import MDI6 from qtpy.QtCore import QSize, Qt, Signal @@ -23,7 +23,8 @@ from ._column_info import ColumnInfo if TYPE_CHECKING: - from typing import Any, Iterator + from collections.abc import Iterable, Iterator + from typing import Any ValueWidget = type[QCheckBox | QSpinBox | QDoubleSpinBox | QComboBox] from PyQt6.QtGui import QAction diff --git a/src/pymmcore_widgets/useq_widgets/_mda_sequence.py b/src/pymmcore_widgets/useq_widgets/_mda_sequence.py index 0205b8f7a..6b8a725d4 100644 --- a/src/pymmcore_widgets/useq_widgets/_mda_sequence.py +++ b/src/pymmcore_widgets/useq_widgets/_mda_sequence.py @@ -3,7 +3,7 @@ from importlib.util import find_spec from itertools import permutations from pathlib import Path -from typing import Sequence, cast +from typing import TYPE_CHECKING, cast import useq from qtpy.QtCore import Qt, Signal @@ -28,6 +28,9 @@ from pymmcore_widgets.useq_widgets._time import TimePlanWidget from pymmcore_widgets.useq_widgets._z import ZPlanWidget +if TYPE_CHECKING: + from collections.abc import Sequence + try: from pint import Quantity diff --git a/src/pymmcore_widgets/useq_widgets/_positions.py b/src/pymmcore_widgets/useq_widgets/_positions.py index 78f7f4c25..3aa39cc67 100644 --- a/src/pymmcore_widgets/useq_widgets/_positions.py +++ b/src/pymmcore_widgets/useq_widgets/_positions.py @@ -3,7 +3,7 @@ import json from dataclasses import dataclass from pathlib import Path -from typing import Sequence, cast +from typing import TYPE_CHECKING, cast import useq from fonticon_mdi6 import MDI6 @@ -24,6 +24,9 @@ from ._column_info import FloatColumn, TextColumn, WdgGetSet, WidgetColumn from ._data_table import DataTableWidget +if TYPE_CHECKING: + from collections.abc import Sequence + OK_CANCEL = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel NULL_SEQUENCE = useq.MDASequence() MAX = 9999999 diff --git a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py index 6a52bb96b..07ea6ecf9 100644 --- a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py +++ b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterable, Mapping, cast +from typing import TYPE_CHECKING, cast import numpy as np import useq @@ -26,6 +26,8 @@ from pymmcore_widgets._util import ResizingGraphicsView if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + from qtpy.QtGui import QMouseEvent Index = int | list[int] | tuple[int] | slice diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py index 4c24c3817..670bbaf56 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Mapping +from typing import TYPE_CHECKING from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( @@ -14,6 +14,9 @@ ) from useq import GridRowsColumns, OrderMode +if TYPE_CHECKING: + from collections.abc import Mapping + class GridRowColumnWidget(QWidget): """Widget to generate a grid of FOVs within a specified area.""" diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py index a5e61d366..baaeaaa10 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py @@ -1,7 +1,7 @@ from __future__ import annotations import random -from typing import Mapping +from typing import TYPE_CHECKING from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( @@ -17,6 +17,9 @@ ) from useq import RandomPoints, RelativePosition, Shape, TraversalOrder +if TYPE_CHECKING: + from collections.abc import Mapping + class RandomPointWidget(QWidget): """Widget to generate random points within a specified area.""" diff --git a/src/pymmcore_widgets/views/_stack_viewer/_stack_viewer.py b/src/pymmcore_widgets/views/_stack_viewer/_stack_viewer.py index 987ed495f..eac639962 100644 --- a/src/pymmcore_widgets/views/_stack_viewer/_stack_viewer.py +++ b/src/pymmcore_widgets/views/_stack_viewer/_stack_viewer.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, cast import numpy as np +import qtpy import superqt from fonticon_mdi6 import MDI6 from qtpy import QtCore, QtWidgets @@ -56,6 +57,13 @@ def __init__( save_button: bool = True, ): super().__init__(parent=parent) + if qtpy.API_NAME.startswith("PySide"): + warnings.warn( + "StackViewer is not tested on PySide, it may not work as expected. " + "You can disable this warning by setting `warn_on_pyside6=False`.", + stacklevel=2, + ) + self._reload_position() self.sequence = sequence self.canvas_size = size diff --git a/tests/test_presets_widget.py b/tests/test_presets_widget.py index 1499bc0c3..8b26e69b5 100644 --- a/tests/test_presets_widget.py +++ b/tests/test_presets_widget.py @@ -11,7 +11,7 @@ from pytestqt.qtbot import QtBot -def test_preset_widget(qtbot: QtBot, global_mmcore: CMMCorePlus): +def test_preset_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: for group in global_mmcore.getAvailableConfigGroups(): wdg = PresetsWidget(group) qtbot.addWidget(wdg) diff --git a/tests/test_stack_viewer.py b/tests/test_stack_viewer.py index 6f7bc4c66..f70bb225d 100644 --- a/tests/test_stack_viewer.py +++ b/tests/test_stack_viewer.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os from typing import TYPE_CHECKING import pytest @@ -19,6 +18,11 @@ from pytestqt.qtbot import QtBot +if qtpy.API_NAME.startswith("PySide"): + pytest.skip( + "Fails too often on CI. Usually (but not only) PySide6", allow_module_level=True + ) + sequence = MDASequence( channels=[{"config": "DAPI", "exposure": 10}, {"config": "FITC", "exposure": 10}], time_plan={"interval": 0.2, "loops": 3}, @@ -171,9 +175,7 @@ def test_disconnect(qtbot: QtBot) -> None: assert not canvas.ready -@pytest.mark.skipif( - bool(os.getenv("CI") and qtpy.API_NAME == "PySide6"), reason="Fails too often on CI" -) +@pytest.mark.skipif(bool(qtpy.API_NAME == "PySide6"), reason="Fails too often on CI") def test_not_ready(qtbot: QtBot) -> None: mmcore = CMMCorePlus.instance() canvas = StackViewer(mmcore=mmcore) From 7668eda444e8dfff5a6ef665165b541901fb6cd3 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 6 Nov 2024 18:46:12 -0500 Subject: [PATCH 32/38] fix: fix include_z when setting stage positions value in PositionTable widget (#381) --- src/pymmcore_widgets/mda/_core_positions.py | 1 + .../useq_widgets/_positions.py | 26 +++++++++++++++---- tests/useq_widgets/test_useq_widgets.py | 26 +++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/pymmcore_widgets/mda/_core_positions.py b/src/pymmcore_widgets/mda/_core_positions.py index b0e35b281..040eaf648 100644 --- a/src/pymmcore_widgets/mda/_core_positions.py +++ b/src/pymmcore_widgets/mda/_core_positions.py @@ -137,6 +137,7 @@ def setValue(self, value: Sequence[Position]) -> None: # type: ignore [override self._set_position_table_editable(False) value = tuple(value) super().setValue(value) + self._update_z_enablement() # ----------------------- private methods ----------------------- diff --git a/src/pymmcore_widgets/useq_widgets/_positions.py b/src/pymmcore_widgets/useq_widgets/_positions.py index 3aa39cc67..c279169a9 100644 --- a/src/pymmcore_widgets/useq_widgets/_positions.py +++ b/src/pymmcore_widgets/useq_widgets/_positions.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import warnings from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, cast @@ -20,6 +21,7 @@ QWidget, ) from superqt.fonticon import icon +from superqt.utils import signals_blocked from ._column_info import FloatColumn, TextColumn, WdgGetSet, WidgetColumn from ._data_table import DataTableWidget @@ -197,6 +199,12 @@ def value( ) -> Sequence[useq.Position]: """Return the current value of the table as a tuple of [useq.Position](https://pymmcore-plus.github.io/useq-schema/schema/axes/#useq.Position). + Note that `exclude_hidden_cols` has the result of: + - excluding the Z position in each of the Positions if + `include_z.isChecked()` is False + - excluding the AF offset in each of the Positions if + `af_per_position.isChecked()` is False + Parameters ---------- exclude_unchecked : bool, optional @@ -248,10 +256,17 @@ def setValue(self, value: Sequence[useq.Position]) -> None: # type: ignore [ove """ _values = [] _use_af = False - for v in value: - if not isinstance(v, useq.Position): # pragma: no cover - raise TypeError(f"Expected useq.Position, got {type(v)}") + value = [useq.Position.model_validate(v) for v in value] + + n_pos_with_z = sum(1 for v in value if v.z is not None) + if (_include_z := n_pos_with_z > 0) and n_pos_with_z < len(value): + warnings.warn( + "Only some positions have a z-position set. Z will be included, " + "but missing z-positions will be set to 0.", + stacklevel=2, + ) + for v in value: _af = {} if v.sequence is not None and v.sequence.autofocus_plan is not None: # set sub-sequence to None if empty or we simply exclude the af plan @@ -275,8 +290,9 @@ def setValue(self, value: Sequence[useq.Position]) -> None: # type: ignore [ove _values.append({**v.model_dump(exclude_unset=True), **_af}) super().setValue(_values) - - self.af_per_position.setChecked(_use_af) + with signals_blocked(self): + self.include_z.setChecked(_include_z) + self.af_per_position.setChecked(_use_af) def save(self, file: str | Path | None = None) -> None: """Save the current positions to a JSON file.""" diff --git a/tests/useq_widgets/test_useq_widgets.py b/tests/useq_widgets/test_useq_widgets.py index 1fef6b61d..f3f448b5e 100644 --- a/tests/useq_widgets/test_useq_widgets.py +++ b/tests/useq_widgets/test_useq_widgets.py @@ -169,6 +169,32 @@ def test_mda_wdg_load_save( assert dest.read_text() == mda_no_meta.yaml(exclude_defaults=True) +def test_mda_wdg_set_value_ignore_z(qtbot: QtBot) -> None: + wdg = MDASequenceWidget() + qtbot.addWidget(wdg) + wdg.show() + + MDA_noZ = useq.MDASequence( + axis_order="p", + stage_positions=[(0, 1), useq.Position(x=42, y=0)], + keep_shutter_open_across=("z",), + ) + assert wdg.stage_positions.include_z.isChecked() + wdg.setValue(MDA_noZ) + assert wdg.value().replace(metadata={}) == MDA_noZ + assert not wdg.stage_positions.include_z.isChecked() + + MDA_partialZ = useq.MDASequence( + axis_order="p", + stage_positions=[(0, 1), useq.Position(x=42, y=0, z=3)], + keep_shutter_open_across=("z",), + ) + + with pytest.warns(match="Only some positions have a z-position"): + wdg.setValue(MDA_partialZ) + assert wdg.stage_positions.include_z.isChecked() + + def test_qquant_line_edit(qtbot: QtBot) -> None: wdg = QTimeLineEdit("1.0 s") wdg.show() From e80b7c5b8428f497e994bd307216dd7e0d6a3981 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Fri, 8 Nov 2024 15:41:38 -0500 Subject: [PATCH 33/38] fix: "range" in range-around z-stack widget starts form 0 (no negative numbers) (#379) fix: range from 0 --- src/pymmcore_widgets/useq_widgets/_z.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pymmcore_widgets/useq_widgets/_z.py b/src/pymmcore_widgets/useq_widgets/_z.py index ba516bfdc..b0bb85c6d 100644 --- a/src/pymmcore_widgets/useq_widgets/_z.py +++ b/src/pymmcore_widgets/useq_widgets/_z.py @@ -146,7 +146,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self.steps.setValue(0) self.range = QDoubleSpinBox() - self.range.setRange(-10_000, 10_000) + self.range.setRange(0, 10_000) self.range.setSingleStep(0.1) self.range.setDecimals(3) self.range.setValue(0) From 7277aa6fdd984b8172eadf30aff2ad9dbda0db7c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 8 Nov 2024 15:41:52 -0500 Subject: [PATCH 34/38] ci(pre-commit.ci): autoupdate (#377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(pre-commit.ci): autoupdate updates: - [github.com/crate-ci/typos: v1.26.0 → v1.27.0](https://github.com/crate-ci/typos/compare/v1.26.0...v1.27.0) - [github.com/astral-sh/ruff-pre-commit: v0.6.9 → v0.7.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.9...v0.7.2) - [github.com/abravalheri/validate-pyproject: v0.20.2 → v0.22](https://github.com/abravalheri/validate-pyproject/compare/v0.20.2...v0.22) - [github.com/pre-commit/mirrors-mypy: v1.11.2 → v1.13.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.2...v1.13.0) * fix: fix pre-commit --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: fdrgsp --- .pre-commit-config.yaml | 8 ++++---- src/pymmcore_widgets/mda/_core_positions.py | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b5f6d794e..d510494ab 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,24 +5,24 @@ ci: repos: - repo: https://github.com/crate-ci/typos - rev: v1.26.0 + rev: v1.27.0 hooks: - id: typos - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 + rev: v0.7.2 hooks: - id: ruff args: [--fix, --unsafe-fixes] - id: ruff-format - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.20.2 + rev: v0.22 hooks: - id: validate-pyproject - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.2 + rev: v1.13.0 hooks: - id: mypy files: "^src/" diff --git a/src/pymmcore_widgets/mda/_core_positions.py b/src/pymmcore_widgets/mda/_core_positions.py index 040eaf648..0eca032f4 100644 --- a/src/pymmcore_widgets/mda/_core_positions.py +++ b/src/pymmcore_widgets/mda/_core_positions.py @@ -391,16 +391,16 @@ def _on_selection_change(self) -> None: # if 'af_per_position' is not checked and the autofocus was not locked # before moving, we do not use autofocus. if table_af_offset is not None or af_offset is not None: - self._mmc.setAutoFocusOffset( - table_af_offset if table_af_offset is not None else af_offset - ) - try: - self._mmc.enableContinuousFocus(False) - self._perform_autofocus() - self._mmc.enableContinuousFocus(af_engaged) - self._mmc.waitForSystem() - except RuntimeError as e: - logger.warning("Hardware autofocus failed. %s", e) + _af = table_af_offset if table_af_offset is not None else af_offset + if _af is not None: + self._mmc.setAutoFocusOffset(_af) + try: + self._mmc.enableContinuousFocus(False) + self._perform_autofocus() + self._mmc.enableContinuousFocus(af_engaged) + self._mmc.waitForSystem() + except RuntimeError as e: + logger.warning("Hardware autofocus failed. %s", e) self._mmc.waitForSystem() From fa04a74385d7f2507625b852332be807a44947a9 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Fri, 8 Nov 2024 15:48:59 -0500 Subject: [PATCH 35/38] feat: indicate pre-init properties in device property browser (#382) * wip add color * use QPalette * Update src/pymmcore_widgets/device_properties/_device_property_table.py Co-authored-by: Talley Lambert * fix: use p icon --------- Co-authored-by: Talley Lambert --- .../device_properties/_device_property_table.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pymmcore_widgets/device_properties/_device_property_table.py b/src/pymmcore_widgets/device_properties/_device_property_table.py index e9b490897..84646a363 100644 --- a/src/pymmcore_widgets/device_properties/_device_property_table.py +++ b/src/pymmcore_widgets/device_properties/_device_property_table.py @@ -123,11 +123,12 @@ def _rebuild_table(self) -> None: self.clearContents() props = list(self._mmc.iterProperties(as_object=True)) self.setRowCount(len(props)) + for i, prop in enumerate(props): - item = QTableWidgetItem(f"{prop.device}-{prop.name}") + extra = " 🅿" if prop.isPreInit() else "" + item = QTableWidgetItem(f"{prop.device}-{prop.name}{extra}") item.setData(self.PROP_ROLE, prop) - icon_string = ICONS.get(prop.deviceType()) - if icon_string: + if icon_string := ICONS.get(prop.deviceType()): item.setIcon(icon(icon_string, color="Gray")) self.setItem(i, 0, item) From 59d46a44b477918ddd5b9afb9a9382b47b4cdb1b Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Sat, 9 Nov 2024 12:38:48 -0500 Subject: [PATCH 36/38] fix: fix z plan and hardware autofocus plan validation error (#378) * fix: fix af-zplan bug * test: add test * fix: af_per_position * test: update * fix: refactor * fix: af per pos * fix: tooltip * fix: msg --------- Co-authored-by: Talley Lambert --- src/pymmcore_widgets/mda/_core_mda.py | 7 ++ .../useq_widgets/_mda_sequence.py | 67 +++++++++++++++++-- tests/useq_widgets/test_useq_widgets.py | 42 ++++++++++++ 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/src/pymmcore_widgets/mda/_core_mda.py b/src/pymmcore_widgets/mda/_core_mda.py index ffc7b8c07..9afc185ef 100644 --- a/src/pymmcore_widgets/mda/_core_mda.py +++ b/src/pymmcore_widgets/mda/_core_mda.py @@ -339,6 +339,13 @@ def _disconnect(self) -> None: self._mmc.mda.events.sequenceStarted.disconnect(self._on_mda_started) self._mmc.mda.events.sequenceFinished.disconnect(self._on_mda_finished) + def _enable_af(self, state: bool, tooltip1: str, tooltip2: str) -> None: + """Override the autofocus enablement to account for the autofocus device.""" + if not self._mmc.getAutoFocusDevice(): + self.stage_positions._update_autofocus_enablement() + return + return super()._enable_af(state, tooltip1, tooltip2) + class _MDAControlButtons(QWidget): """Run, pause, and cancel buttons at the bottom of the MDA Widget.""" diff --git a/src/pymmcore_widgets/useq_widgets/_mda_sequence.py b/src/pymmcore_widgets/useq_widgets/_mda_sequence.py index 6b8a725d4..0be577554 100644 --- a/src/pymmcore_widgets/useq_widgets/_mda_sequence.py +++ b/src/pymmcore_widgets/useq_widgets/_mda_sequence.py @@ -13,6 +13,7 @@ QFileDialog, QHBoxLayout, QLabel, + QMessageBox, QPushButton, QSizePolicy, QVBoxLayout, @@ -24,9 +25,9 @@ from pymmcore_widgets.useq_widgets._channels import ChannelTable from pymmcore_widgets.useq_widgets._checkable_tabwidget_widget import CheckableTabWidget from pymmcore_widgets.useq_widgets._grid import GridPlanWidget -from pymmcore_widgets.useq_widgets._positions import PositionTable +from pymmcore_widgets.useq_widgets._positions import AF_DEFAULT_TOOLTIP, PositionTable from pymmcore_widgets.useq_widgets._time import TimePlanWidget -from pymmcore_widgets.useq_widgets._z import ZPlanWidget +from pymmcore_widgets.useq_widgets._z import Mode, ZPlanWidget if TYPE_CHECKING: from collections.abc import Sequence @@ -62,6 +63,11 @@ def _check_order(x: str, first: str, second: str) -> bool: ): if _check_order(x, first, second): ALLOWED_ORDERS.discard(x) +AF_TOOLTIP = "Use Hardware Autofocus on the selected axes." +AF_DISABLED_TOOLTIP = ( + "The hardware autofocus cannot be used with absolute Z positions " + "(TOP_BOTTOM mode)." +) class MDATabs(CheckableTabWidget): @@ -199,11 +205,13 @@ def __init__(self, parent: QWidget | None = None) -> None: self.use_af_t.toggled.connect(self.valueChanged) self.use_af_g.toggled.connect(self.valueChanged) - self.setToolTip("Use Hardware Autofocus on the selected axes.") + self.setToolTip(AF_TOOLTIP) def value(self) -> tuple[str, ...]: """Return the autofocus axes.""" af_axis: tuple[str, ...] = () + if not self.isEnabled(): + return af_axis if self.use_af_p.isChecked(): af_axis += ("p",) if self.use_af_t.isChecked(): @@ -282,6 +290,9 @@ def __init__( self.axis_order.setToolTip("Slowest to fastest axis order.") self.axis_order.setMinimumWidth(80) + # used in _validate_af_with_z_plan to store state of the autofocus per position + self._use_af_per_pos: bool = False + # -------------- Other Widgets -------------- # QLabel with standard warning icon to indicate time overflow @@ -340,9 +351,9 @@ def __init__( self.channels.valueChanged.connect(self.valueChanged) self.time_plan.valueChanged.connect(self.valueChanged) self.stage_positions.valueChanged.connect(self.valueChanged) - self.z_plan.valueChanged.connect(self.valueChanged) + self.z_plan.valueChanged.connect(self._validate_af_with_z_plan) self.grid_plan.valueChanged.connect(self.valueChanged) - self.tab_wdg.tabChecked.connect(self._update_available_axis_orders) + self.tab_wdg.tabChecked.connect(self._on_tab_checked) self.axis_order.currentTextChanged.connect(self.valueChanged) self.valueChanged.connect(self._update_time_estimate) @@ -496,6 +507,52 @@ def _settings_extensions(self) -> str: # Only JSON return "All (*.json);;JSON (*.json)" + def _enable_af(self, state: bool, tooltip1: str, tooltip2: str) -> None: + """Enable or disable autofocus settings.""" + self.af_axis.setEnabled(state) + self.af_axis.setToolTip(tooltip1) + self.stage_positions.af_per_position.setEnabled(state) + self.stage_positions.af_per_position.setToolTip(tooltip2) + if not state: + self.stage_positions.af_per_position.setChecked(state) + else: + # re-enable autofocus per position only if it was checked before + self.stage_positions.af_per_position.setChecked(self._use_af_per_pos) + + def _validate_af_with_z_plan(self) -> None: + """Check if the autofocus plan can be used with the current Z Plan. + + If the Z Plan is set to TOP_BOTTOM, the autofocus plan cannot be used. + """ + if self.z_plan.mode() == Mode.TOP_BOTTOM: + self._use_af_per_pos = self.stage_positions.af_per_position.isChecked() + self._enable_af(False, AF_DISABLED_TOOLTIP, AF_DISABLED_TOOLTIP) + if self.af_axis.use_af_p.isChecked(): + QMessageBox.warning( + self, + "Autofocus Plan Disabled", + "The hardware autofocus cannot be used with absolute Z positions " + "(TOP_BOTTOM mode). It has been disabled.\n\n" + "To re-enable it, set the Z Plan Mode to a relative position" + " (RANGE_AROUND or ABOVE_BELOW mode).", + buttons=QMessageBox.StandardButton.Ok, + defaultButton=QMessageBox.StandardButton.Ok, + ) + else: + self._enable_af(True, AF_TOOLTIP, AF_DEFAULT_TOOLTIP) + + self.valueChanged.emit() + + def _on_tab_checked(self, tab_idx: int) -> None: + """Before updating autofocus axes, check if the autofocus plan can be used.""" + if tab_idx == self.tab_wdg.indexOf(self.z_plan): + if self.tab_wdg.isChecked(self.z_plan): + self._validate_af_with_z_plan() + else: + self._enable_af(True, AF_TOOLTIP, AF_DEFAULT_TOOLTIP) + + self._update_available_axis_orders() + def _on_af_toggled(self, checked: bool) -> None: # if the 'af_per_position' checkbox in the PositionTable is checked, set checked # also the autofocus p axis checkbox. diff --git a/tests/useq_widgets/test_useq_widgets.py b/tests/useq_widgets/test_useq_widgets.py index f3f448b5e..bd93a9620 100644 --- a/tests/useq_widgets/test_useq_widgets.py +++ b/tests/useq_widgets/test_useq_widgets.py @@ -3,11 +3,13 @@ import enum from datetime import timedelta from typing import TYPE_CHECKING +from unittest.mock import patch import pint import pytest import useq from qtpy.QtCore import Qt, QTimer +from qtpy.QtWidgets import QMessageBox import pymmcore_widgets from pymmcore_widgets.useq_widgets import ( @@ -507,3 +509,43 @@ def test_parse_time() -> None: assert parse_timedelta("3:40:10.500") == timedelta( hours=3, minutes=40, seconds=10.5 ) + + +def test_autofocus_with_z_plans(qtbot: QtBot) -> None: + wdg = MDASequenceWidget() + qtbot.addWidget(wdg) + wdg.show() + + wdg.tab_wdg.setChecked(wdg.stage_positions, True) + + assert not wdg.tab_wdg.isChecked(wdg.z_plan) + assert wdg.af_axis.isEnabled() + assert wdg.stage_positions.af_per_position.isEnabled() + wdg.af_axis.setValue(("p", "t")) + assert wdg.af_axis.value() == ("p", "t") + + def _qmsgbox(*args, **kwargs): + return True + + with patch.object(QMessageBox, "warning", _qmsgbox): + wdg.tab_wdg.setChecked(wdg.z_plan, True) + + assert wdg.z_plan.mode() == _z.Mode.TOP_BOTTOM + assert not wdg.af_axis.isEnabled() + assert not wdg.stage_positions.af_per_position.isEnabled() + assert wdg.af_axis.value() == () + + with patch.object(QMessageBox, "warning", _qmsgbox): + wdg.tab_wdg.setChecked(wdg.z_plan, False) + + assert wdg.af_axis.isEnabled() + assert wdg.stage_positions.af_per_position.isEnabled() + + with patch.object(QMessageBox, "warning", _qmsgbox): + wdg.tab_wdg.setChecked(wdg.z_plan, True) + + with patch.object(QMessageBox, "warning", _qmsgbox): + wdg.z_plan.setValue(useq.ZRangeAround(range=4, step=0.2)) + + assert wdg.af_axis.isEnabled() + assert wdg.stage_positions.af_per_position.isEnabled() From 33c135046228b028ab3326c716038d4182770ab3 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Sat, 9 Nov 2024 13:46:51 -0500 Subject: [PATCH 37/38] fix: fix af per pos bug on prop changed (#384) --- src/pymmcore_widgets/mda/_core_mda.py | 6 +++++- src/pymmcore_widgets/mda/_core_positions.py | 5 ++++- tests/test_useq_core_widgets.py | 20 +++++++++++--------- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/pymmcore_widgets/mda/_core_mda.py b/src/pymmcore_widgets/mda/_core_mda.py index 9afc185ef..cd4b15900 100644 --- a/src/pymmcore_widgets/mda/_core_mda.py +++ b/src/pymmcore_widgets/mda/_core_mda.py @@ -150,7 +150,11 @@ def value(self) -> MDASequence: # if there is an autofocus_plan but the autofocus_motor_offset is None, set it # to the current value - if (afplan := val.autofocus_plan) and afplan.autofocus_motor_offset is None: + if ( + self._mmc.getAutoFocusDevice() + and (afplan := val.autofocus_plan) + and afplan.autofocus_motor_offset is None + ): p2 = afplan.replace(autofocus_motor_offset=self._mmc.getAutoFocusOffset()) replace["autofocus_plan"] = p2 diff --git a/src/pymmcore_widgets/mda/_core_positions.py b/src/pymmcore_widgets/mda/_core_positions.py index 0eca032f4..6f5af1717 100644 --- a/src/pymmcore_widgets/mda/_core_positions.py +++ b/src/pymmcore_widgets/mda/_core_positions.py @@ -259,7 +259,7 @@ def _on_sys_config_loaded(self) -> None: self._update_autofocus_enablement() def _on_property_changed(self, device: str, prop: str, _val: str = "") -> None: - """Update the autofocus device combo box when the autofocus device changes.""" + """Enable/Disable stages columns.""" if device == "Core": if prop == "XYStage": self._update_xy_enablement() @@ -291,6 +291,9 @@ def _update_autofocus_enablement(self) -> None: """Update the autofocus device combo box.""" af_device = self._mmc.getAutoFocusDevice() self.af_per_position.setEnabled(bool(af_device)) + # also hide the AF column if the autofocus device is not available + if not af_device: + self.af_per_position.setChecked(False) self.af_per_position.setToolTip( AF_DEFAULT_TOOLTIP if af_device else "AutoFocus device unavailable." ) diff --git a/tests/test_useq_core_widgets.py b/tests/test_useq_core_widgets.py index ee8795b4e..e0b93ff73 100644 --- a/tests/test_useq_core_widgets.py +++ b/tests/test_useq_core_widgets.py @@ -195,15 +195,6 @@ def test_core_connected_position_wdg_property_changed( # stage device is set as default device. mmc = global_mmcore - with qtbot.waitSignal(mmc.events.propertyChanged): - if stage == "XY": - mmc.setProperty("Core", "XYStage", "") - elif stage == "Z": - mmc.setProperty("Core", "Focus", "") - elif stage == "Autofocus": - mmc.setProperty("Core", "AutoFocus", "") - mmc.waitForSystem() - wdg = MDAWidget() qtbot.addWidget(wdg) wdg.show() @@ -213,6 +204,17 @@ def test_core_connected_position_wdg_property_changed( wdg.setValue(MDA) + pos_table.af_per_position.setChecked(True) + + with qtbot.waitSignal(mmc.events.propertyChanged): + if stage == "XY": + mmc.setProperty("Core", "XYStage", "") + elif stage == "Z": + mmc.setProperty("Core", "Focus", "") + elif stage == "Autofocus": + mmc.setProperty("Core", "AutoFocus", "") + mmc.waitForSystem() + # stage is not set as default device _assert_position_wdg_state(stage, pos_table, is_hidden=True) From 1cda2b70d289e62ec1863ed17910a71e1cb751c4 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Tue, 12 Nov 2024 07:16:02 -0500 Subject: [PATCH 38/38] feat: use QRadioButtons to select z_plan (#385) * feat: use radiobuttons * tweak * fix: fix setMode --------- Co-authored-by: Talley Lambert --- src/pymmcore_widgets/mda/_core_z.py | 2 +- src/pymmcore_widgets/useq_widgets/_z.py | 117 +++++++++++------------- tests/useq_widgets/test_useq_widgets.py | 7 +- 3 files changed, 61 insertions(+), 65 deletions(-) diff --git a/src/pymmcore_widgets/mda/_core_z.py b/src/pymmcore_widgets/mda/_core_z.py index 97f529f56..b2e988b20 100644 --- a/src/pymmcore_widgets/mda/_core_z.py +++ b/src/pymmcore_widgets/mda/_core_z.py @@ -51,7 +51,7 @@ def __init__( def setMode( self, - mode: Mode | Literal["top_bottom", "range_around", "above_below"] | None = None, + mode: Mode | Literal["top_bottom", "range_around", "above_below"], ) -> None: super().setMode(mode) self.bottom_btn.setVisible(self._mode == Mode.TOP_BOTTOM) diff --git a/src/pymmcore_widgets/useq_widgets/_z.py b/src/pymmcore_widgets/useq_widgets/_z.py index b0bb85c6d..40c872401 100644 --- a/src/pymmcore_widgets/useq_widgets/_z.py +++ b/src/pymmcore_widgets/useq_widgets/_z.py @@ -1,12 +1,13 @@ from __future__ import annotations import enum -from typing import TYPE_CHECKING, Final, Literal, cast +from typing import Final, Literal import useq from fonticon_mdi6 import MDI6 from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( + QAbstractButton, QButtonGroup, QDoubleSpinBox, QGridLayout, @@ -15,7 +16,6 @@ QPushButton, QRadioButton, QSpinBox, - QToolButton, QVBoxLayout, QWidget, ) @@ -24,11 +24,6 @@ from pymmcore_widgets._util import SeparatorWidget -if TYPE_CHECKING: - from PyQt6.QtGui import QAction, QActionGroup -else: - from qtpy.QtGui import QAction, QActionGroup - class Mode(enum.Enum): """Recognized ZPlanWidget modes.""" @@ -67,52 +62,44 @@ def __init__(self, parent: QWidget | None = None) -> None: # to store a "suggested" step size self._suggested: float | None = None - # #################### Mode Buttons #################### - - # ------------------- actions ---------- + self._mode: Mode = Mode.TOP_BOTTOM - self._mode_top_bot = QAction( - icon(MDI6.arrow_expand_vertical, scale_factor=1), "Mark top and bottom." - ) - self._mode_top_bot.setCheckable(True) - self._mode_top_bot.setData(Mode.TOP_BOTTOM) - self._mode_top_bot.triggered.connect(self.setMode) + # #################### Mode Buttons #################### - self._mode_range = QAction( - icon(MDI6.arrow_split_horizontal, scale_factor=1), - "Range symmetric around reference.", + self._btn_top_bot = QRadioButton("Top/Bottom") + self._btn_top_bot.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._btn_top_bot.setIcon(icon(MDI6.arrow_expand_vertical)) + self._btn_top_bot.setToolTip("Mark top and bottom.") + self._btn_range = QRadioButton("Range Around (Symmetric)") + self._btn_range.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._btn_range.setIcon(icon(MDI6.arrow_split_horizontal)) + self._btn_range.setToolTip("Range symmetric around reference.") + self._button_above_below = QRadioButton("Range Asymmetric") + self._button_above_below.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._button_above_below.setIcon(icon(MDI6.arrow_expand_up)) + self._button_above_below.setToolTip( + "Range asymmetrically above/below reference." ) - self._mode_range.setCheckable(True) - self._mode_range.setData(Mode.RANGE_AROUND) - self._mode_range.triggered.connect(self.setMode) - self._mode_above_below = QAction( - icon(MDI6.arrow_expand_up, scale_factor=1), - "Range asymmetrically above/below reference.", - ) - self._mode_above_below.setCheckable(True) - self._mode_above_below.setData(Mode.ABOVE_BELOW) - self._mode_above_below.triggered.connect(self.setMode) - - self._mode_group = QActionGroup(self) - self._mode_group.addAction(self._mode_top_bot) - self._mode_group.addAction(self._mode_range) - self._mode_group.addAction(self._mode_above_below) - - # ------------------- - - btn_top_bot = QToolButton() - btn_top_bot.setDefaultAction(self._mode_top_bot) - btn_range = QToolButton() - btn_range.setDefaultAction(self._mode_range) - button_above_below = QToolButton() - button_above_below.setDefaultAction(self._mode_above_below) - - btn_layout = QHBoxLayout() - btn_layout.addWidget(btn_top_bot) - btn_layout.addWidget(btn_range) - btn_layout.addWidget(button_above_below) - btn_layout.addStretch() + self._mode_btn_group = QButtonGroup() + self._mode_btn_group.addButton(self._btn_top_bot) + self._mode_btn_group.addButton(self._btn_range) + self._mode_btn_group.addButton(self._button_above_below) + self._mode_btn_group.buttonToggled.connect(self.setMode) + + # radio buttons on the top row + btn_wdg = QWidget() + btn_layout = QHBoxLayout(btn_wdg) + btn_layout.setContentsMargins(0, 0, 0, 0) + btn_layout.addWidget(self._btn_top_bot, 0) + btn_layout.addWidget(self._btn_range, 0) + btn_layout.addWidget(self._button_above_below, 1) + + # FIXME: On Windows 11, buttons within an inner widget of a ScrollArea + # are filled in with the accent color, making it very difficult to see + # which radio button is checked. This HACK solves the issue. It's + # likely future Qt versions will fix this. + btn_wdg.setStyleSheet("QRadioButton {color: none}") # #################### Value Widgets #################### @@ -248,7 +235,6 @@ def __init__(self, parent: QWidget | None = None) -> None: left_half = QVBoxLayout() left_half.addWidget(self._range_readout) - # left_half.addWidget(self.leave_shutter_open) right_half = QVBoxLayout() right_half.addWidget(self._bottom_to_top) @@ -261,7 +247,7 @@ def __init__(self, parent: QWidget | None = None) -> None: below_grid.addLayout(right_half) layout = QVBoxLayout(self) - layout.addLayout(btn_layout) + layout.addWidget(btn_wdg) layout.addWidget(SeparatorWidget()) layout.addLayout(self._grid_layout) layout.addStretch() @@ -270,13 +256,12 @@ def __init__(self, parent: QWidget | None = None) -> None: # #################### Defaults #################### self.setMode(Mode.TOP_BOTTOM) - # self.setSuggestedStep(1) # ------------------------- Public API ------------------------- def setMode( self, - mode: Mode | Literal["top_bottom", "range_around", "above_below", None] = None, + mode: Mode | Literal["top_bottom", "range_around", "above_below"], ) -> None: """Set the current mode. @@ -288,27 +273,35 @@ def setMode( The mode to set. By default, None. If None, the mode is determined by the sender().data(), for internal usage. """ - if isinstance(mode, str): - mode = Mode(mode) - elif isinstance(mode, (bool, type(None))): - mode = cast("QAction", self.sender()).data() - - self._mode = cast(Mode, mode) + if isinstance(mode, QRadioButton): + btn_map: dict[QAbstractButton, Mode] = { + self._btn_top_bot: Mode.TOP_BOTTOM, + self._btn_range: Mode.RANGE_AROUND, + self._button_above_below: Mode.ABOVE_BELOW, + } + self._mode = btn_map[mode] + elif isinstance(mode, str): + self._mode = Mode(mode) + else: + self._mode = mode if self._mode is Mode.TOP_BOTTOM: - self._mode_top_bot.setChecked(True) + with signals_blocked(self._mode_btn_group): + self._btn_top_bot.setChecked(True) self._set_row_visible(ROW_RANGE_AROUND, False) self._set_row_visible(ROW_ABOVE_BELOW, False) self._set_row_visible(ROW_TOP_BOTTOM, True) elif self._mode is Mode.RANGE_AROUND: - self._mode_range.setChecked(True) + with signals_blocked(self._mode_btn_group): + self._btn_range.setChecked(True) self._set_row_visible(ROW_TOP_BOTTOM, False) self._set_row_visible(ROW_ABOVE_BELOW, False) self._set_row_visible(ROW_RANGE_AROUND, True) elif self._mode is Mode.ABOVE_BELOW: - self._mode_above_below.setChecked(True) + with signals_blocked(self._mode_btn_group): + self._button_above_below.setChecked(True) self._set_row_visible(ROW_RANGE_AROUND, False) self._set_row_visible(ROW_TOP_BOTTOM, False) self._set_row_visible(ROW_ABOVE_BELOW, True) diff --git a/tests/useq_widgets/test_useq_widgets.py b/tests/useq_widgets/test_useq_widgets.py index bd93a9620..fd704ff9c 100644 --- a/tests/useq_widgets/test_useq_widgets.py +++ b/tests/useq_widgets/test_useq_widgets.py @@ -358,12 +358,15 @@ def test_z_plan_widget(qtbot: QtBot) -> None: assert wdg.mode() == _z.Mode.TOP_BOTTOM assert wdg.top.isVisible() assert not wdg.above.isVisible() - wdg._mode_range.trigger() + assert wdg._btn_top_bot.isChecked() + wdg.setMode(_z.Mode.RANGE_AROUND) assert wdg.range.isVisible() assert not wdg.top.isVisible() - wdg._mode_above_below.trigger() + assert wdg._btn_range.isChecked() + wdg.setMode(_z.Mode.ABOVE_BELOW) assert wdg.above.isVisible() assert not wdg.range.isVisible() + assert wdg._button_above_below.isChecked() assert wdg.step.value() == 1 wdg.setSuggestedStep(0.5)