diff --git a/src/pymmcore_widgets/control/_stage_widget.py b/src/pymmcore_widgets/control/_stage_widget.py index e3b740a1b..6edcf500e 100644 --- a/src/pymmcore_widgets/control/_stage_widget.py +++ b/src/pymmcore_widgets/control/_stage_widget.py @@ -1,25 +1,31 @@ from __future__ import annotations from itertools import product -from typing import cast +from typing import TYPE_CHECKING, cast from fonticon_mdi6 import MDI6 from pymmcore_plus import CMMCorePlus, DeviceType, Keyword -from qtpy.QtCore import Qt, QTimerEvent, Signal +from qtpy.QtCore import QEvent, QObject, Qt, QTimerEvent, Signal +from qtpy.QtGui import QContextMenuEvent from qtpy.QtWidgets import ( QCheckBox, QDoubleSpinBox, QGridLayout, + QHBoxLayout, QLabel, + QMenu, QPushButton, QRadioButton, QSpinBox, QVBoxLayout, QWidget, ) -from superqt.fonticon import setTextIcon +from superqt.fonticon import icon, setTextIcon from superqt.utils import signals_blocked +if TYPE_CHECKING: + from typing import Any + CORE = Keyword.CoreDevice XY_STAGE = Keyword.CoreXYStage FOCUS = Keyword.CoreFocus @@ -69,18 +75,43 @@ def __init__(self, glyph: str, xmag: int, ymag: int, parent: QWidget | None = No ) +class MoveStageSpinBox(QDoubleSpinBox): + """Common behavior for SpinBoxes that move stages.""" + + def __init__( + self, + label: str, + minimum: float = -99999, + maximum: float = 99999, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.setToolTip(f"Set {label} in µm") + self.setSuffix(" µm") + self.setMinimum(minimum) + self.setMaximum(maximum) + self.setDecimals(1) + self.setAttribute(Qt.WidgetAttribute.WA_MacShowFocusRect, 0) + self.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # enable custom context menu handling for right-click events + self.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) + + class HaltButton(QPushButton): - def __init__(self, core: CMMCorePlus, parent: QWidget | None = None): - super().__init__("STOP!", parent=parent) + def __init__(self, device: str, core: CMMCorePlus, parent: QWidget | None = None): + super().__init__(parent=parent) + self._device = device self._core = core - self.setStyleSheet("color: red; font-weight: bold;") + self.setIcon(icon(MDI6.close_octagon, color=(255, 0, 0))) + self.setToolTip("Halt stage movement") + self.setText("STOP!") 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) + self._core.stop(self._device) class StageMovementButtons(QWidget): @@ -109,15 +140,8 @@ def __init__( 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 = MoveStageSpinBox(label="step size", minimum=0) 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) @@ -191,9 +215,12 @@ class StageWidget(QWidget): Stage device. levels: int | None: Number of "arrow" buttons per widget per direction, by default, 2. + absolute_positioning: bool | None + If True, the position displays can be edited to set absolute positions. + If False, the position displays cannot be edited. 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. + If True, the position displays will appear below the move buttons. + If False, the position displays will appear to the right of the move buttons. parent : QWidget | None Optional parent widget. mmcore : CMMCorePlus | None @@ -210,6 +237,7 @@ def __init__( device: str, levels: int = 2, *, + absolute_positioning: bool = False, position_label_below: bool = True, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None, @@ -233,9 +261,32 @@ def __init__( self._move_btns = StageMovementButtons(self._levels, self._is_2axis) self._step = self._move_btns.step_size - self._pos_label = QLabel() - self._pos_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._pos = QHBoxLayout() + self._pos_boxes: list[MoveStageSpinBox] = [] + self._pos_menu = QMenu(self) + self._pos_toggle_action = self._pos_menu.addAction("Enable Editing") + self._pos_toggle_action.setCheckable(True) + self._pos_toggle_action.setChecked(absolute_positioning) + self._pos_toggle_action.triggered.connect(self.enable_absolute_positioning) + if self._is_2axis: + self._pos.addWidget(QLabel("X: ")) + self._x_pos = MoveStageSpinBox(label="X") + self._pos_boxes.append(self._x_pos) + self._pos.addWidget(self._x_pos) + self._x_pos.editingFinished.connect(self._move_x_absolute) + + self._pos.addWidget(QLabel(f"{self._Ylabel}: ")) + self._y_pos = MoveStageSpinBox(label="Y") + self._pos_boxes.append(self._y_pos) + self._y_pos.editingFinished.connect(self._move_y_absolute) + self._pos.addWidget(self._y_pos) + + for box in self._pos_boxes: + box.installEventFilter(self) + self._pos.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self._halt = HaltButton(device, self._mmc, self) self._poll_cb = QCheckBox("Poll") self.snap_checkbox = QCheckBox(text="Snap on Click") self._invert_x = QCheckBox(text="Invert X") @@ -261,15 +312,16 @@ def __init__( 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.addWidget(self._halt) 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) + main_layout.insertLayout(2, self._pos) else: move_btns_layout = cast("QGridLayout", self._move_btns.layout()) move_btns_layout.addLayout( - self._pos_label, 4, 4, 2, 2, Qt.AlignmentFlag.AlignBottom + self._pos, 4, 4, 2, 2, Qt.AlignmentFlag.AlignBottom ) if not self._is_2axis: @@ -292,6 +344,7 @@ def __init__( # INITIALIZATION ---------------------------------------- self._update_position_from_core() + self.enable_absolute_positioning(absolute_positioning) self._set_as_default() def step(self) -> float: @@ -302,9 +355,24 @@ def setStep(self, step: float) -> None: """Set the step size.""" self._step.setValue(step) + def enable_absolute_positioning(self, enabled: bool) -> None: + """Toggles whether the position spinboxes can be edited by the user. + + Parameters + ---------- + enabled: bool: + If True, the position spinboxes will be enabled for user editing. + If False, the position spinboxes will be disabled for user editing. + """ + self._pos_toggle_action.setChecked(enabled) + for box in self._pos_boxes: + box.setEnabled(enabled) + def _enable_wdg(self, enabled: bool) -> None: self._step.setEnabled(enabled) self._move_btns.setEnabled(enabled) + for box in self._pos_boxes: + box.setEnabled(enabled and self._pos_toggle_action.isChecked()) self.snap_checkbox.setEnabled(enabled) self._set_as_default_btn.setEnabled(enabled) self._poll_cb.setEnabled(enabled) @@ -359,31 +427,66 @@ def timerEvent(self, event: QTimerEvent | None) -> None: self._update_position_from_core() super().timerEvent(event) + def eventFilter(self, obj: QObject | None, event: QEvent | None) -> bool: + # NB QAbstractSpinBox has its own Context Menu handler, which conflicts + # with the one we want to generate. So we intercept the event here >:) + # See https://stackoverflow.com/a/71126504 + if obj in self._pos_boxes and isinstance(event, QContextMenuEvent): + self._pos_menu.exec_(event.globalPos()) + return True + return super().eventFilter(obj, event) # type: ignore [no-any-return] + 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}" + self._x_pos.setValue(x) + self._y_pos.setValue(y) else: - lbl = f"{self._Ylabel}: {self._mmc.getPosition(self._device):.01f}" - self._pos_label.setText(lbl) + y = self._mmc.getPosition(self._device) + self._y_pos.setValue(y) 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(xmag, ymag) + self._move_stage_relative(xmag, ymag) - def _move_stage(self, x: float, y: float) -> None: + def _move_stage_relative(self, x: float, y: float) -> None: 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}") + self._mmc.logMessage(f"Error moving stage: {e}") # pragma: no cover + else: + if self.snap_checkbox.isChecked(): + self._mmc.snap() + + def _move_x_absolute(self) -> None: + x = self._x_pos.value() + try: + y = self._mmc.getYPosition(self._device) + self._mmc.setXYPosition(self._device, x, y) + except Exception as e: + self._mmc.logMessage(f"Error moving stage: {e}") # pragma: no cover + else: + if self.snap_checkbox.isChecked(): + self._mmc.snap() + + def _move_y_absolute(self) -> None: + y = self._y_pos.value() + try: + if self._is_2axis: + x = self._mmc.getXPosition(self._device) + self._mmc.setXYPosition(self._device, x, y) + else: + self._mmc.setPosition(self._device, y) + except Exception as e: + self._mmc.logMessage(f"Error moving stage: {e}") # pragma: no cover else: if self.snap_checkbox.isChecked(): self._mmc.snap() diff --git a/tests/test_stage_widget.py b/tests/test_stage_widget.py index fa54f9f0a..48cb34f29 100644 --- a/tests/test_stage_widget.py +++ b/tests/test_stage_widget.py @@ -11,7 +11,7 @@ def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: # test XY stage - stage_xy = StageWidget("XY", levels=3) + stage_xy = StageWidget("XY", levels=3, absolute_positioning=True) qtbot.addWidget(stage_xy) assert global_mmcore.getXYStageDevice() == "XY" @@ -25,7 +25,8 @@ def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: stage_xy.setStep(5.0) assert stage_xy.step() == 5.0 - assert stage_xy._pos_label.text() == "X: -0.0 Y: -0.0" + assert stage_xy._x_pos.value() == 0 + assert stage_xy._y_pos.value() == 0 x_pos = global_mmcore.getXPosition() y_pos = global_mmcore.getYPosition() @@ -39,7 +40,8 @@ def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: < global_mmcore.getYPosition() < (y_pos + (stage_xy.step() * 3)) + 1 ) - assert stage_xy._pos_label.text() == "X: -0.0 Y: 15.0" + assert stage_xy._x_pos.value() == 0 + assert stage_xy._y_pos.value() == 15 xy_left_1 = stage_xy._move_btns.layout().itemAtPosition(3, 2) global_mmcore.waitForDevice("XY") @@ -49,18 +51,34 @@ def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: < global_mmcore.getXPosition() < (x_pos - stage_xy.step()) + 1 ) - assert stage_xy._pos_label.text() == "X: -5.0 Y: 15.0" + assert stage_xy._x_pos.value() == -5 + assert stage_xy._y_pos.value() == 15 + + global_mmcore.waitForDevice("XY") + stage_xy._x_pos.setValue(5) + stage_xy._x_pos.editingFinished.emit() + assert 4 < global_mmcore.getXPosition() < 6 + + global_mmcore.waitForDevice("XY") + stage_xy._y_pos.setValue(5) + stage_xy._y_pos.editingFinished.emit() + assert 4 < global_mmcore.getYPosition() < 6 global_mmcore.waitForDevice("XY") global_mmcore.setXYPosition(0.0, 0.0) - y_pos = global_mmcore.getYPosition() - x_pos = global_mmcore.getXPosition() - assert stage_xy._pos_label.text() == "X: -0.0 Y: -0.0" + assert stage_xy._x_pos.value() == 0 + assert stage_xy._y_pos.value() == 0 stage_xy.snap_checkbox.setChecked(True) with qtbot.waitSignal(global_mmcore.events.imageSnapped): global_mmcore.waitForDevice("XY") xy_up_3.widget().click() + with qtbot.waitSignal(global_mmcore.events.imageSnapped): + stage_xy._x_pos.setValue(10) + stage_xy._x_pos.editingFinished.emit() + with qtbot.waitSignal(global_mmcore.events.imageSnapped): + stage_xy._y_pos.setValue(10) + stage_xy._y_pos.editingFinished.emit() # test Z stage stage_z = StageWidget("Z", levels=3) @@ -83,7 +101,7 @@ def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: stage_z.setStep(15.0) assert stage_z.step() == 15.0 - assert stage_z._pos_label.text() == "Z: 0.0" + assert stage_z._y_pos.value() == 0 z_pos = global_mmcore.getPosition() assert z_pos == 0.0 @@ -95,17 +113,25 @@ def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: < global_mmcore.getPosition() < (z_pos + (stage_z.step() * 2)) + 1 ) - assert stage_z._pos_label.text() == "Z: 30.0" + assert stage_z._y_pos.value() == 30 + + global_mmcore.waitForDevice("Z") + stage_z._y_pos.setValue(5) + stage_z._y_pos.editingFinished.emit() + assert 4 < global_mmcore.getPosition() < 6 global_mmcore.waitForDevice("Z") global_mmcore.setPosition(0.0) z_pos = global_mmcore.getPosition() - assert stage_z._pos_label.text() == "Z: 0.0" + assert stage_z._y_pos.value() == 0 stage_z.snap_checkbox.setChecked(True) with qtbot.waitSignal(global_mmcore.events.imageSnapped): global_mmcore.waitForDevice("Z") z_up_2.widget().click() + with qtbot.waitSignal(global_mmcore.events.imageSnapped): + stage_xy._y_pos.setValue(10) + stage_xy._y_pos.editingFinished.emit() # disconnect assert global_mmcore.getFocusDevice() == "Z" @@ -119,6 +145,50 @@ def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: assert not stage_z1._set_as_default_btn.isChecked() +def test_enable_position_buttons(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: + # Absolute positioning disabled + stage_xy = StageWidget("XY", levels=3) + # Phase 1: position buttons cannot be enabled before the menu action is toggled + qtbot.addWidget(stage_xy) + assert not stage_xy._x_pos.isEnabled() + assert not stage_xy._y_pos.isEnabled() + stage_xy._enable_wdg(False) + assert not stage_xy._x_pos.isEnabled() + assert not stage_xy._y_pos.isEnabled() + stage_xy._enable_wdg(True) + assert not stage_xy._x_pos.isEnabled() + assert not stage_xy._y_pos.isEnabled() + # Phase 2: Trigger menu action, buttons can now be enabled + stage_xy._pos_toggle_action.trigger() + assert stage_xy._x_pos.isEnabled() + assert stage_xy._y_pos.isEnabled() + stage_xy._enable_wdg(False) + assert not stage_xy._x_pos.isEnabled() + assert not stage_xy._y_pos.isEnabled() + stage_xy._enable_wdg(True) + assert stage_xy._x_pos.isEnabled() + assert stage_xy._y_pos.isEnabled() + stage_xy._pos_toggle_action.trigger() + assert not stage_xy._x_pos.isEnabled() + assert not stage_xy._y_pos.isEnabled() + # Phase 3: Set absolute positioning using API + # Should be identical to Phase 2 + stage_xy.enable_absolute_positioning(True) + assert stage_xy._pos_toggle_action.isChecked() + assert stage_xy._x_pos.isEnabled() + assert stage_xy._y_pos.isEnabled() + stage_xy._enable_wdg(False) + assert not stage_xy._x_pos.isEnabled() + assert not stage_xy._y_pos.isEnabled() + stage_xy._enable_wdg(True) + assert stage_xy._x_pos.isEnabled() + assert stage_xy._y_pos.isEnabled() + stage_xy.enable_absolute_positioning(False) + assert not stage_xy._pos_toggle_action.isChecked() + assert not stage_xy._x_pos.isEnabled() + assert not stage_xy._y_pos.isEnabled() + + def test_invert_axis(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: stage_xy = StageWidget("XY", levels=3) qtbot.addWidget(stage_xy)