feat: stage motor entry values, apply moves in one click
CI / lint (push) Skipped
CI / test (3.11) (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped

Chi/Phi boxes in the Smargon tab no longer move on Enter: edits stage
the target (orange), the Move button sends all staged axes at once
(green), and boxes return to neutral when the motor reports arrival.
Out-of-range entries stay red and are never sent; limit tips pop as
tooltips. MotorMoveGroup is opt-in per box so other panels can adopt
the same logic later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 10:56:32 +02:00
co-authored by Claude Fable 5
parent b8177bd764
commit e18c7feded
4 changed files with 243 additions and 14 deletions
+20 -14
View File
@@ -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)
+24
View File
@@ -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;
}
+132
View File
@@ -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()))
+67
View File
@@ -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"