diff --git a/src/aare/gui/panels/smargon_panel.py b/src/aare/gui/panels/smargon_panel.py index 3ed71dce..949d6c80 100644 --- a/src/aare/gui/panels/smargon_panel.py +++ b/src/aare/gui/panels/smargon_panel.py @@ -5,6 +5,7 @@ from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QWidget from aare.gui.widgets.button_with_payload import ButtonWithPayload +from aare.gui.widgets.motor_move_group import MotorMoveGroup from aare.gui.widgets.number_line_edit import NumberLineEdit from aare.gui.widgets.title_label import TitleLabel @@ -59,28 +60,34 @@ class SmargonPanel(QWidget): grid_layout = QGridLayout(self) grid_layout.addWidget( - TitleLabel("Smargon", self, collapsible=True, default_collapsed=False), 0, 0, 1, 6 + TitleLabel("Smargon", self, collapsible=True, default_collapsed=False), 0, 0, 1, 7 ) grid_layout.addWidget(QLabel("Chi", parent=self), 1, 0) self.chi_enter = NumberLineEdit(-0.2, 40, decimals=1, parent=self) - self.chi_enter.newValue.connect(self.chi) grid_layout.addWidget(self.chi_enter, 1, 1) grid_layout.addWidget(QLabel("°", parent=self), 1, 2) grid_layout.addWidget(QLabel("Phi", parent=self), 1, 3) self.phi_enter = NumberLineEdit(-0.2, 360, decimals=1, parent=self) - self.phi_enter.newValue.connect(self.phi) grid_layout.addWidget(self.phi_enter, 1, 4) grid_layout.addWidget(QLabel("°", parent=self), 1, 5) + # Chi/Phi are staged (orange) and only sent on Move (green until the + # motor reports the target reached) — see MotorMoveGroup. + self.move_group = MotorMoveGroup(parent=self) + self.move_group.add_box("chi", self.chi_enter) + self.move_group.add_box("phi", self.phi_enter) + self.move_group.applied.connect(self._move_axes) + grid_layout.addWidget(self.move_group.button, 1, 6) + self.home_button = QPushButton("Move home", parent=self) - grid_layout.addWidget(self.home_button, 2, 0, 1, 6) + grid_layout.addWidget(self.home_button, 2, 0, 1, 7) self.home_button.clicked.connect(self.home) self.move_panel = SmargonMoveWidget(parent=self) - grid_layout.addWidget(self.move_panel, 3, 0, 1, 6) + grid_layout.addWidget(self.move_panel, 3, 0, 1, 7) self.move_panel.smargon_rel.connect(self.smargon_rel) grid_layout.addWidget(QLabel("Step", parent=self), 4, 0) @@ -98,18 +105,17 @@ class SmargonPanel(QWidget): # TODO move SMARGON_HOME to REDIS, allow GUI to read this value self.smargon.emit(SmargonCoordinate(sh_mm=Coordinate(x=0, y=0, z=18), phi_deg=0, chi_deg=0)) - @Slot(float) - def phi(self, f: float): - self.smargon.emit(SmargonCoordinate(phi_deg=f)) - - @Slot(float) - def chi(self, f: float): - self.smargon.emit(SmargonCoordinate(chi_deg=f)) + @Slot(dict) + def _move_axes(self, targets: dict): + # only the staged axes move — absent keys stay None (no motion) + self.smargon.emit( + SmargonCoordinate(chi_deg=targets.get("chi"), phi_deg=targets.get("phi")) + ) @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): - self.chi_enter.update_value(s.geom.smargon.chi_deg) - self.phi_enter.update_value(s.geom.smargon.phi_deg) + self.move_group.update_actual("chi", s.geom.smargon.chi_deg) + self.move_group.update_actual("phi", s.geom.smargon.phi_deg) self._geom = s.geom @Slot(Coordinate) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 9394a5c7..1f35f723 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -348,6 +348,12 @@ INPUT_BG = "rgba(255, 255, 255, 33%)" INPUT_INVALID_BG = "#e9c4cf" # red 20% over latte base INPUT_DISABLED_BG = "#e6e9ef" # latte mantle INPUT_DISABLED_INVALID_BG = "#ecdae2" # faint red wash +# Motor batch-move states (MotorMoveGroup "movestate" dynamic property): +# staged-but-not-sent target vs motor in motion — see motor_move_group.py. +INPUT_PENDING_BG = "#f2cdba" # peach 25% over latte base +INPUT_MOVING_BG = "#c3ddc3" # green 25% over latte base +DARK_INPUT_PENDING_BG = "#473741" # warn (copper) 25% over bg +DARK_INPUT_MOVING_BG = "#2f4e5d" # green 25% over bg # -- Status bar flags (Catppuccin Latte) ------------------------------------ STATUS_OK = "#40a02b" # closed / idle / owned / tell ok (green) @@ -715,6 +721,16 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: background-color: $input_disabled_bg; } + /* Motor batch-move states (before the invalid rules on purpose: equal + specificity, so out-of-range red must come last to win). */ + QLineEdit[movestate="pending"] { + background-color: $input_pending_bg; + } + + QLineEdit[movestate="moving"] { + background-color: $input_moving_bg; + } + QLineEdit[invalid="true"] { background-color: $input_invalid_bg; } @@ -1380,6 +1396,14 @@ def _sunset_stylesheet() -> str: background-color: $dark_disabled; } + QLineEdit[movestate="pending"] { + background-color: $dark_input_pending_bg; + } + + QLineEdit[movestate="moving"] { + background-color: $dark_input_moving_bg; + } + QLineEdit[invalid="true"] { background-color: $dark_error_bg; } diff --git a/src/aare/gui/widgets/motor_move_group.py b/src/aare/gui/widgets/motor_move_group.py new file mode 100644 index 00000000..f04fcb91 --- /dev/null +++ b/src/aare/gui/widgets/motor_move_group.py @@ -0,0 +1,132 @@ +"""Batch entry -> apply for motor value boxes. + +Why: typing into a motor box used to fire the move on Enter, one axis at a +time. Operators want to stage several targets, review them, then start all +motors with one click. The group tracks a per-box state machine: + + neutral (theme default) --user edit--> pending (orange) + pending --Move clicked--> moving (green) + moving --actual reaches target--> neutral + +Colors are QSS dynamic properties ("movestate" on the box), styled per theme +in styles.py — same pattern as NumberLineEdit's "invalid" property, so the +existing out-of-range red still wins while typing. + +Modular on purpose: any panel opts a NumberLineEdit in with add_box(); boxes +not registered keep their old behavior. +""" + +from PySide6.QtCore import QEvent, QObject, QPoint, Qt, Signal, Slot +from PySide6.QtWidgets import QPushButton, QToolTip + +from aare.gui.widgets.number_line_edit import NumberLineEdit + + +class MotorMoveGroup(QObject): + # {name: target} for every box that was pending when Move was clicked + applied = Signal(dict) + + def __init__(self, button_text: str = "Move", parent=None): + super().__init__(parent) + self.button = QPushButton(button_text) + self.button.setEnabled(False) + self.button.clicked.connect(self._apply) + self._boxes: dict[str, NumberLineEdit] = {} + self._tols: dict[str, float] = {} + self._state: dict[str, str] = {} # "" (neutral) | "pending" | "moving" + self._targets: dict[str, float] = {} + + def add_box(self, name: str, box: NumberLineEdit, tol: float = 0.1): + # tol is the "at target" window — hardware never lands exactly on the + # setpoint, so this stays a per-box knob (encoder resolution differs + # per motor). + self._boxes[name] = box + self._tols[name] = tol + self._state[name] = "" + box.textEdited.connect(lambda _text, n=name: self._on_edited(n)) + # Below-min can only be judged on Enter (typing "3" may become "30"), + # and the validator swallows editingFinished for out-of-range text — + # so catch the key directly. + box.installEventFilter(self) + + # -- panel-facing API ---------------------------------------------------- + @Slot(str, float) + def update_actual(self, name: str, value: float): + """Feed the motor's actual position. Neutral boxes track it (same as + the old direct update_value call); pending/moving boxes keep showing + the user's target until the move completes.""" + box = self._boxes[name] + state = self._state[name] + if state == "moving" and abs(value - self._targets[name]) <= self._tols[name]: + self._set_state(name, "") + state = "" + if state == "": + box.update_value(value) + + # -- internals ----------------------------------------------------------- + def _on_edited(self, name: str): + box = self._boxes[name] + try: + value = float(box.text()) + except ValueError: + # incomplete entry ("", "-", "1e"): stay/become pending, no tips + self._set_state(name, "pending") + return + top = box.validator.top() + if value > top: + QToolTip.showText( + box.mapToGlobal(QPoint(0, box.height())), + f"Maximum value: {box.to_string(top)}", + box, + ) + # editing back to the current position cancels the pending move + if abs(value - box.saved_value) <= self._tols[name]: + self._set_state(name, "") + else: + self._set_state(name, "pending") + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.KeyPress and event.key() in ( + Qt.Key.Key_Return, + Qt.Key.Key_Enter, + ): + for box in self._boxes.values(): + if obj is box: + try: + value = float(box.text()) + except ValueError: + break + bottom = box.validator.bottom() + if value < bottom: + QToolTip.showText( + box.mapToGlobal(QPoint(0, box.height())), + f"Too small — minimum value: {box.to_string(bottom)}", + box, + ) + break + return super().eventFilter(obj, event) + + @Slot() + def _apply(self): + targets = {} + for name, box in self._boxes.items(): + # out-of-range text stays pending (red via the invalid property); + # only valid targets are sent + if self._state[name] == "pending" and box.validate(box.text()): + targets[name] = box.value + self._targets[name] = box.value + self._set_state(name, "moving") + if targets: + self.applied.emit(targets) + # ponytail: no timeout — a move that never reaches target leaves the + # box green until the user re-edits it; add a watchdog if that bites. + + def _set_state(self, name: str, state: str): + box = self._boxes[name] + if box.property("movestate") != state: + box.setProperty("movestate", state) + # property selectors only re-evaluate on repolish + box.style().unpolish(box) + box.style().polish(box) + self._state[name] = state + self.button.setEnabled(any(s == "pending" for s in self._state.values())) diff --git a/tests/unit/gui/test_motor_move_group.py b/tests/unit/gui/test_motor_move_group.py new file mode 100644 index 00000000..daf08375 --- /dev/null +++ b/tests/unit/gui/test_motor_move_group.py @@ -0,0 +1,67 @@ +"""MotorMoveGroup is motor protection UX: typing must never start a move — +targets are staged (orange), sent only by the Move button (green), and the +box returns to neutral when the motor actually arrives.""" + +import pytest + +from aare.gui.widgets.motor_move_group import MotorMoveGroup +from aare.gui.widgets.number_line_edit import NumberLineEdit + + +@pytest.fixture +def chi(qtbot): + box = NumberLineEdit(-0.2, 40, decimals=1) + qtbot.addWidget(box) + group = MotorMoveGroup() + group.add_box("chi", box) + return group, box + + +def _type(qtbot, box, text): + box.clear() + qtbot.keyClicks(box, text) + + +def test_stage_apply_settle(chi, qtbot): + group, box = chi + sent = [] + group.applied.connect(sent.append) + + _type(qtbot, box, "25.0") + assert box.property("movestate") == "pending" + assert group.button.isEnabled() + assert not sent # typing (even Enter) must not move the motor + + group.button.click() + assert sent == [{"chi": 25.0}] + assert box.property("movestate") == "moving" + assert not group.button.isEnabled() + + group.update_actual("chi", 10.0) # still travelling + assert box.property("movestate") == "moving" + assert box.value == 25.0 # box keeps showing the target + + group.update_actual("chi", 25.05) # within tol -> arrived + assert box.property("movestate") == "" + group.update_actual("chi", 3.0) # neutral boxes track the actual again + assert box.value == 3.0 + + +def test_edit_back_to_current_cancels_pending(chi, qtbot): + group, box = chi + _type(qtbot, box, "5.0") + assert group.button.isEnabled() + _type(qtbot, box, "0.0") # back to the actual position + assert box.property("movestate") == "" + assert not group.button.isEnabled() + + +def test_over_max_is_invalid_and_never_sent(chi, qtbot): + group, box = chi + sent = [] + group.applied.connect(sent.append) + _type(qtbot, box, "500") + assert box.property("invalid") is True # red via existing validator path + group.button.click() + assert not sent # out-of-range target is never applied + assert box.property("movestate") == "pending"