diff --git a/examples/stage_widget.py b/examples/stage_widget.py index 8058e3d47..80daaaf57 100644 --- a/examples/stage_widget.py +++ b/examples/stage_widget.py @@ -15,23 +15,17 @@ 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.addWidget(StageWidget(device=stage, position_labels_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..e8883cd7a 100644 --- a/src/pymmcore_widgets/_stage_widget.py +++ b/src/pymmcore_widgets/_stage_widget.py @@ -1,14 +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, + QFormLayout, QGridLayout, QHBoxLayout, QLabel, @@ -21,37 +22,150 @@ 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: 40px; + } + MoveStageButton:hover:!pressed { + color: rgb(0, 255, 0); + } + MoveStageButton:pressed { + color: rgb(0, 100, 0); + } + """ + ) + + +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.setToolTip("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): + item.widget().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 +177,6 @@ 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. parent : QWidget | None Optional parent widget. mmcore : CMMCorePlus | None @@ -75,153 +187,141 @@ class StageWidget(QWidget): """ # 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_labels_below: bool | None = None, 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() + is_2axis = self._dtype is DeviceType.XYStage + Y = "Y" if 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 + self._move_btns = StageMovementButtons(self._levels, is_2axis) + self._step = self._move_btns.step_size - def setStep(self, step: float) -> None: - """Set the step size.""" - self._step.setValue(step) - - 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._poslabel_x = QDoubleSpinBox() + self._poslabel_x.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons) + self._poslabel_x.setRange(-99999999, 99999999) + self._poslabel_yz = QDoubleSpinBox() + self._poslabel_yz.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons) + self._poslabel_yz.setRange(-99999999, 99999999) + 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 {Y}") + 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) + + # position label + # this can appear either below or to the right of the move buttons + + if position_labels_below is None: + self._pos_labels_below = is_2axis + else: + self._pos_labels_below = position_labels_below + + if self._pos_labels_below: + label_form = QHBoxLayout() + label_form.setContentsMargins(0, 0, 0, 0) + if is_2axis: + label_form.addWidget(QLabel("X:"), 0) + label_form.addWidget(self._poslabel_x, 1) + label_form.addWidget(QLabel(f"{Y}:"), 0) + else: + label_form.addWidget(QLabel(f"{Y}:"), 0) + label_form.addWidget(self._poslabel_yz, 1) + main_layout.insertLayout(2, label_form) + else: + label_form = QFormLayout() + label_form.setContentsMargins(0, 0, 0, 0) + label_form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow + ) + label_form.setFormAlignment(Qt.AlignmentFlag.AlignCenter) + if is_2axis: + label_form.addRow("X:", self._poslabel_x) + label_form.addRow(f"{Y}:", self._poslabel_yz) + move_btns_layout = cast("QGridLayout", self._move_btns.layout()) + move_btns_layout.addLayout( + label_form, 4, 4, 2, 2, Qt.AlignmentFlag.AlignBottom + ) + + if not is_2axis: + self._poslabel_x.hide() 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 + self._mmc.events.XYStagePositionChanged.connect(self._update_position_label) elif self._dtype is DeviceType.Stage: - event = self._mmc.events.stagePositionChanged - event.connect(self._update_position_label) + self._mmc.events.stagePositionChanged.connect(self._update_position_label) + self.destroyed.connect(self._disconnect) + + # INITIALIZATION ---------------------------------------- + + self._update_position_label() + 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: @@ -244,141 +344,92 @@ def _enable_and_update(self, enable: bool) -> None: self._enable_wdg(True) self._update_position_label() else: - self._readout.setText(f"{self._device} not loaded.") + # self._poslabel_x.setText(f"{self._device} not loaded.") self._enable_wdg(False) 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) + if self._dtype is DeviceType.XYStage: + if self._mmc.getXYStageDevice() == self._device: + self._set_as_default_btn.setChecked(True) + elif self._dtype is DeviceType.Stage: + if self._mmc.getFocusDevice() == self._device: + self._set_as_default_btn.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) + self._mmc.setProperty(CORE, XY_STAGE, self._device) elif ( not state and len(self._mmc.getLoadedDevicesOfType(DeviceType.XYStage)) == 1 ): - with signals_blocked(self.radiobutton): - self.radiobutton.setChecked(True) + with signals_blocked(self._set_as_default_btn): + self._set_as_default_btn.setChecked(True) else: - self._mmc.setProperty("Core", "XYStage", "") + self._mmc.setProperty(CORE, XY_STAGE, "") elif self._dtype is DeviceType.Stage: if state: - self._mmc.setProperty("Core", "Focus", self._device) + 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) + with signals_blocked(self._set_as_default_btn): + self._set_as_default_btn.setChecked(True) else: - self._mmc.setProperty("Core", "Focus", "") + self._mmc.setProperty(CORE, FOCUS, "") def _on_prop_changed(self, dev: str, prop: str, val: str) -> None: - if dev != "Core": - 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) + if dev == CORE and ( + (self._dtype is DeviceType.XYStage and prop == XY_STAGE) + or (self._dtype is DeviceType.Stage and prop == FOCUS) + ): + 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 timerEvent(self, event: QTimerEvent) -> None: + if event.timerId() == self._poll_timer_id: + self._update_position_label() + super().timerEvent(event) 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() + if self._device not in self._mmc.getLoadedDevicesOfType(self._dtype): + return - def _on_click(self) -> None: - btn: QPushButton = self.sender() - xmag, ymag = self.BTNS[f"{PREFIX}.{btn.text()}"][-2:] + if self._dtype is DeviceType.XYStage: + self._poslabel_x.setValue(self._mmc.getXPosition(self._device)) + self._poslabel_yz.setValue(self._mmc.getYPosition(self._device)) + elif self._dtype is DeviceType.Stage: + self._poslabel_yz.setValue(self._mmc.getPosition(self._device)) + 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._dtype is DeviceType.XYStage: + 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) diff --git a/tests/test_stage_widget.py b/tests/test_stage_widget.py index a5eba7ddf..12db308c0 100644 --- a/tests/test_stage_widget.py +++ b/tests/test_stage_widget.py @@ -9,30 +9,31 @@ 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._poslabel_x.value() == 0 + assert stage_xy._poslabel_yz.value() == 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 @@ -40,10 +41,10 @@ def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus): < (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}" + round(global_mmcore.getYPosition(), 2) + assert stage_xy._poslabel_x.value() == label_x - 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 ( @@ -52,15 +53,15 @@ def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus): < (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}" + round(global_mmcore.getYPosition(), 2) + assert stage_xy._poslabel_x.value() == label_x - assert stage_xy._readout.text() != "XY: -0.0, -0.0" + assert stage_xy._poslabel_x.value() != 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._poslabel_x.value() == 0 stage_xy.snap_checkbox.setChecked(True) with qtbot.waitSignal(global_mmcore.events.imageSnapped): @@ -75,37 +76,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._poslabel_yz.value() == 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._poslabel_yz.value() == round(global_mmcore.getPosition(), 2) 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._poslabel_yz.value() == 0 stage_z.snap_checkbox.setChecked(True) with qtbot.waitSignal(global_mmcore.events.imageSnapped): @@ -114,25 +115,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 +156,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