diff --git a/pyproject.toml b/pyproject.toml index 3c7142e0..82e0d317 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,6 +88,9 @@ markers = [ requires = ["setuptools>=75.6.0"] build-backend = "setuptools.build_meta" +[tool.setuptools.package-data] +"aare.gui.graphics" = ["*.svg", "*.png"] + [tool.black] line-length = 100 skip-magic-trailing-comma = true diff --git a/src/aare/gui/graphics/aare_banner_blue.png b/src/aare/gui/graphics/aare_banner_blue.png new file mode 100644 index 00000000..39dfa9c5 Binary files /dev/null and b/src/aare/gui/graphics/aare_banner_blue.png differ diff --git a/src/aare/gui/gui.py b/src/aare/gui/gui.py index 1b1fb78f..6559b148 100644 --- a/src/aare/gui/gui.py +++ b/src/aare/gui/gui.py @@ -22,7 +22,7 @@ def main(): basedir = os.path.dirname(__file__) icon_path = os.path.join(basedir, "graphics/aaregui_logo.svg") # SVG looks strange on consoles...only show one line - banner_path = os.path.join(basedir, "graphics/aare_banner.png") + banner_path = os.path.join(basedir, "graphics/aare_banner_blue.png") except Exception: logger.exception("Failed to load resources for splash screen") sys.exit(1) diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py index cf9ad649..c9c471d2 100644 --- a/src/aare/gui/panels/data_collection_settings.py +++ b/src/aare/gui/panels/data_collection_settings.py @@ -19,11 +19,18 @@ from PySide6.QtWidgets import ( from aare.gui.panels.file_path_panel import FilePathPanel from aare.gui.panels.fluorescence_data_collection import FluorescenceDataCollectionPanel from aare.gui.panels.manual_sample_panel import ManualSamplePanel +from aare.gui.panels.monochromator_panel import ( + ENERGY_AT_TARGET_TOL_KEV, + ENERGY_MAX_KEV, + ENERGY_MIN_KEV, + ENERGY_RANGE_TIP, +) from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel from aare.gui.panels.smart_rotation_panel import SimpleRotationSettingsPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager from aare.gui.styles import BANNER_TAB_GAP +from aare.gui.widgets.motor_move_group import SpinMoveState from aare.gui.widgets.title_label import TitleLabel, tighten_column @@ -111,11 +118,18 @@ class DataCollectionSettings(QFrame): # energy without leaving the experiment configuration. self.energy_spin = QDoubleSpinBox(parent=self) self.energy_spin.setDecimals(3) - self.energy_spin.setRange(1.0, 30.0) + self.energy_spin.setRange(ENERGY_MIN_KEV, ENERGY_MAX_KEV) + self.energy_spin.setToolTip(ENERGY_RANGE_TIP) self.energy_spin.setSingleStep(0.1) self.energy_spin.setValue(12.0) self.change_energy_button = QPushButton("Change Energy", parent=self) self.change_energy_button.clicked.connect(self._emit_change_energy) + # MotorMoveGroup semantics for the button-gated spin, same as the + # Beamline setup row: neutral tracks the readback, edit stages + # pending, the click turns moving until arrival. + self._energy_state = SpinMoveState( + self.energy_spin, self.change_energy_button, tol=ENERGY_AT_TARGET_TOL_KEV, parent=self + ) energy_row = QWidget(self) energy_layout = QHBoxLayout(energy_row) energy_layout.setContentsMargins(0, 0, 0, 0) @@ -216,6 +230,8 @@ class DataCollectionSettings(QFrame): text = "— / —" else: text = f"{energy:.3f} keV / {s.diffraction.wavelength_angstrom:.4f} Å" + # 0.0 stays out: syncing the fallback would clamp the spin to min + self._energy_state.update_actual(energy) # Guarded: runs per DAQ tick (2 Hz), skip the repaint when unchanged. if self.current_energy_label.text() != text: self.current_energy_label.setText(text) diff --git a/src/aare/gui/panels/fluorescence_data_collection.py b/src/aare/gui/panels/fluorescence_data_collection.py index f0aa1df4..244fced3 100644 --- a/src/aare/gui/panels/fluorescence_data_collection.py +++ b/src/aare/gui/panels/fluorescence_data_collection.py @@ -16,12 +16,16 @@ class FluorescenceDataCollectionPanel(QWidget): # Beam transmission (0..1) lay.addWidget(QLabel("Beam transmission", self), 0, 0) - self.transmission = NumberLineEdit(0.0, 1.0, decimals=4, default=0.1, parent=self) + self.transmission = NumberLineEdit( + 0.0, 1.0, decimals=4, default=0.1, parent=self, track_pending=True + ) lay.addWidget(self.transmission, 0, 1) # Exposure time (seconds) lay.addWidget(QLabel("Exposure time", self), 1, 0) - self.exposure = NumberLineEdit(0.01, 60.0, decimals=3, default=1.0, parent=self) + self.exposure = NumberLineEdit( + 0.01, 60.0, decimals=3, default=1.0, parent=self, track_pending=True + ) lay.addWidget(self.exposure, 1, 1) lay.addWidget(QLabel("s", self), 1, 2) diff --git a/src/aare/gui/panels/log_panel.py b/src/aare/gui/panels/log_panel.py index 22b197a3..65530d37 100644 --- a/src/aare/gui/panels/log_panel.py +++ b/src/aare/gui/panels/log_panel.py @@ -168,7 +168,8 @@ class RuntimeNotificationWidget(QFrame): self._set_level(level) self._title.setText(self._full_title) self._message.setText(self._full_message) - self._clear_button.setVisible(not sticky) + # allow dismising of notification, if bad, then remove + self._clear_button.setVisible(True) self._body.setVisible(True) self._minimised = False self._minimise_button.setText("—") diff --git a/src/aare/gui/panels/monochromator_panel.py b/src/aare/gui/panels/monochromator_panel.py index 58bbcf33..28f787e5 100644 --- a/src/aare/gui/panels/monochromator_panel.py +++ b/src/aare/gui/panels/monochromator_panel.py @@ -3,8 +3,22 @@ from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QDoubleSpinBox, QGridLayout, QLabel, QPushButton, QWidget from aare.gui.styles import THEME_SUNRISE, status_colors +from aare.gui.widgets.motor_move_group import SpinMoveState from aare.gui.widgets.title_label import TitleLabel +# TODO: placeholder range (was an arbitrary 1-30; nothing downstream validates — +# daq.change_energy forwards straight to bec) — confirm the real monochromator +# limits with the Beamline Scientist. Shared by both Set Energy rows. +ENERGY_MIN_KEV = 4.0 +ENERGY_MAX_KEV = 20.0 +# "arrived" window for the moving->neutral transition; per-hardware knob like +# MotorMoveGroup's tol (the mono never lands exactly on the setpoint) +ENERGY_AT_TARGET_TOL_KEV = 0.001 +ENERGY_RANGE_TIP = ( + f"Minimum: {ENERGY_MIN_KEV:.3f} keV\nMaximum: {ENERGY_MAX_KEV:.3f} keV\n" + "Range to be confirmed with Beamline Scientist" +) + class MonochromatorPanel(QWidget): mono_pitch_scan = Signal() @@ -43,7 +57,8 @@ class MonochromatorPanel(QWidget): self.energy_spin = QDoubleSpinBox(parent=self) self.energy_spin.setDecimals(3) - self.energy_spin.setRange(1.0, 30.0) + self.energy_spin.setRange(ENERGY_MIN_KEV, ENERGY_MAX_KEV) + self.energy_spin.setToolTip(ENERGY_RANGE_TIP) self.energy_spin.setSingleStep(0.1) self.energy_spin.setValue(12.0) grid_layout.addWidget(self.energy_spin, 3, 1) @@ -51,6 +66,11 @@ class MonochromatorPanel(QWidget): self.change_energy_button = QPushButton("Change Energy", parent=self) self.change_energy_button.clicked.connect(self._emit_change_energy) grid_layout.addWidget(self.change_energy_button, 3, 2) + # MotorMoveGroup semantics for the button-gated spin: neutral tracks + # the readback, edit stages pending, click turns moving until arrival. + self._energy_state = SpinMoveState( + self.energy_spin, self.change_energy_button, tol=ENERGY_AT_TARGET_TOL_KEV, parent=self + ) # Fast shutter row: status left, Open/Close buttons right — same # rich-text scheme as the status bar flag so the two readouts match. @@ -109,12 +129,8 @@ class MonochromatorPanel(QWidget): @Slot(DAQStatusModel) def update_daq_status(self, status: DAQStatusModel): energy = status.diffraction.energy_keV - # 0.0 is the server's detector-unavailable fallback, and the - # wavelength property divides by it — guard before touching it. - if not energy: - text = "— / —" - else: - text = f"{energy:.3f} keV / {status.diffraction.wavelength_angstrom:.4f} Å" + text = f"{energy:.3f} keV / {status.diffraction.wavelength_angstrom:.4f} Å" + self._energy_state.update_actual(energy) # Guarded: runs per DAQ tick (2 Hz), skip the repaint when unchanged. if self.current_energy_label.text() != text: self.current_energy_label.setText(text) diff --git a/src/aare/gui/panels/rotation_data_collection.py b/src/aare/gui/panels/rotation_data_collection.py index c55dc304..15d5af6e 100644 --- a/src/aare/gui/panels/rotation_data_collection.py +++ b/src/aare/gui/panels/rotation_data_collection.py @@ -58,7 +58,9 @@ class RotationDataCollectionPanel(ScanSettingsPanel): self._total_time = 0.0 self._layout.addWidget(QLabel("Start angle", parent=self), 3, 0) - self.start_angle = NumberLineEdit(-720, 720.0, 0.0, decimals=3, parent=self) + self.start_angle = NumberLineEdit( + -720, 720.0, 0.0, decimals=3, parent=self, track_pending=True + ) self._layout.addWidget(self.start_angle, 3, 1, 1, 2) self._layout.addWidget(QLabel("°", parent=self), 3, 3) @@ -72,7 +74,9 @@ class RotationDataCollectionPanel(ScanSettingsPanel): ) self._layout.addWidget(QLabel("Image angle", parent=self), 5, 0) - self.screening_image_angle = NumberLineEdit(0, 90.0, 0.5, decimals=3, parent=self) + self.screening_image_angle = NumberLineEdit( + 0, 90.0, 0.5, decimals=3, parent=self, track_pending=True + ) self._layout.addWidget(self.screening_image_angle, 5, 1, 1, 3) self._layout.addWidget(QLabel("°", parent=self), 5, 4) @@ -81,7 +85,7 @@ class RotationDataCollectionPanel(ScanSettingsPanel): "daq.data_collection_settings.default_screening_settings.exp_time_s", 0.1 ) self.screening_image_time_enter = NumberLineEdit( - 0.0005, 10.0, default_screening_exp_time, decimals=4, parent=self + 0.0005, 10.0, default_screening_exp_time, decimals=4, parent=self, track_pending=True ) self._layout.addWidget(self.screening_image_time_enter, 6, 1, 1, 3) self._layout.addWidget(QLabel("s", parent=self), 6, 4) diff --git a/src/aare/gui/panels/smart_rotation_panel.py b/src/aare/gui/panels/smart_rotation_panel.py index 22855bef..51bf8320 100644 --- a/src/aare/gui/panels/smart_rotation_panel.py +++ b/src/aare/gui/panels/smart_rotation_panel.py @@ -51,13 +51,17 @@ class SimpleRotationSettingsPanel(QWidget): # Visible resolution (entry) self._layout.addWidget(QLabel("Visible resolution", parent=self), 0, 0) - self.visible_res_enter = NumberLineEdit(0.8, 10.0, decimals=2, default=2.0, parent=self) + self.visible_res_enter = NumberLineEdit( + 0.8, 10.0, decimals=2, default=2.0, parent=self, track_pending=True + ) self._layout.addWidget(self.visible_res_enter, 0, 1, 1, 3) self._layout.addWidget(QLabel("Å", parent=self), 0, 4) self.visible_res_enter.newValue.connect(self.set_visible_resolution) self._layout.addWidget(QLabel("Start angle", parent=self), 1, 0) - self.start_angle_enter = NumberLineEdit(-720, 720.0, 0.0, decimals=3, parent=self) + self.start_angle_enter = NumberLineEdit( + -720, 720.0, 0.0, decimals=3, parent=self, track_pending=True + ) self._layout.addWidget(self.start_angle_enter, 1, 1, 1, 3) self._layout.addWidget(QLabel("°", parent=self), 1, 4) @@ -69,20 +73,24 @@ class SimpleRotationSettingsPanel(QWidget): # Angular range (entry) self._layout.addWidget(QLabel("Total angle", parent=self), 2, 0) self.angular_range_enter = NumberLineEdit( - 5.0, 1000.0, decimals=3, default=360.0, parent=self + 5.0, 1000.0, decimals=3, default=360.0, parent=self, track_pending=True ) self._layout.addWidget(self.angular_range_enter, 2, 1, 1, 3) self._layout.addWidget(QLabel("°", parent=self), 2, 4) self.visible_res_enter.newValue.connect(self.set_total_angle) self._layout.addWidget(QLabel("Image angle", parent=self), 3, 0) - self.image_angle_enter = NumberLineEdit(0.001, 1.000, decimals=3, default=0.2, parent=self) + self.image_angle_enter = NumberLineEdit( + 0.001, 1.000, decimals=3, default=0.2, parent=self, track_pending=True + ) self._layout.addWidget(self.image_angle_enter, 3, 1, 1, 3) self._layout.addWidget(QLabel("°", parent=self), 3, 4) self.image_angle_enter.newValue.connect(self.set_image_angle) self._layout.addWidget(QLabel("Temperature", parent=self), 4, 0) - self.temp_enter = NumberLineEdit(80, 330, decimals=2, default=100.0, parent=self) + self.temp_enter = NumberLineEdit( + 80, 330, decimals=2, default=100.0, parent=self, track_pending=True + ) self._layout.addWidget(self.temp_enter, 4, 1, 1, 3) self._layout.addWidget(QLabel("K", parent=self), 4, 4) self.temp_enter.newValue.connect(self.set_temperature) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 7d94a09d..41254aeb 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -744,12 +744,15 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: } /* 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"] { + specificity, so out-of-range red must come last to win). Spinboxes + (Set Energy) carry the property on the box itself — their inner + QLineEdit is transparent by design (rule below), so it can't show + the state. */ + QLineEdit[movestate="pending"], QAbstractSpinBox[movestate="pending"] { background-color: $input_pending_bg; } - QLineEdit[movestate="moving"] { + QLineEdit[movestate="moving"], QAbstractSpinBox[movestate="moving"] { background-color: $input_moving_bg; } @@ -1429,11 +1432,11 @@ def _sunset_stylesheet() -> str: background-color: $dark_disabled; } - QLineEdit[movestate="pending"] { + QLineEdit[movestate="pending"], QAbstractSpinBox[movestate="pending"] { background-color: $dark_input_pending_bg; } - QLineEdit[movestate="moving"] { + QLineEdit[movestate="moving"], QAbstractSpinBox[movestate="moving"] { background-color: $dark_input_moving_bg; } diff --git a/src/aare/gui/widgets/motor_move_group.py b/src/aare/gui/widgets/motor_move_group.py index 2248ad48..3315d4b0 100644 --- a/src/aare/gui/widgets/motor_move_group.py +++ b/src/aare/gui/widgets/motor_move_group.py @@ -17,11 +17,73 @@ not registered keep their old behavior. """ from PySide6.QtCore import QEvent, QObject, QPoint, Qt, Signal, Slot -from PySide6.QtWidgets import QPushButton, QToolTip +from PySide6.QtWidgets import QDoubleSpinBox, QPushButton, QToolTip from aare.gui.widgets.number_line_edit import NumberLineEdit +class SpinMoveState(QObject): + """MotorMoveGroup's state machine for a single QDoubleSpinBox whose apply + gate is a button (the Set Energy rows): neutral tracks the readback, a + user edit stages pending (orange) and enables the button, the click turns + moving (green), and arrival within tol returns to neutral. The movestate + property sits on the spinbox itself — its inner QLineEdit is transparent + by theme design, so coloring it shows nothing. The caller keeps its own + clicked connection for the actual send; this object only tracks state.""" + + def __init__(self, spin: QDoubleSpinBox, button: QPushButton, tol: float, parent=None): + super().__init__(parent) + self._spin = spin + self._button = button + # "at target" window: the mono never lands exactly on the setpoint + self._tol = tol + self._state = "" # "" (neutral) | "pending" | "moving" + self._target = 0.0 + self._actual: float | None = None + # valueChanged fires for our own readback sync too; the flag is how a + # user edit is told apart (a spinbox has no textEdited-only signal for + # arrows/wheel). + self._syncing = False + spin.valueChanged.connect(self._on_value_changed) + button.clicked.connect(self._on_applied) + button.setEnabled(False) + + @Slot(float) + def _on_value_changed(self, value: float): + if self._syncing: + return + # editing back to the current readback cancels the pending change + if self._actual is not None and abs(value - self._actual) <= self._tol: + self._set_state("") + else: + self._set_state("pending") + + @Slot() + def _on_applied(self): + self._target = self._spin.value() + self._set_state("moving") + + @Slot(float) + def update_actual(self, value: float): + """Feed the readback. Neutral spins track it; pending/moving keep + showing the user's target until the move completes.""" + self._actual = value + if self._state == "moving" and abs(value - self._target) <= self._tol: + self._set_state("") + if self._state == "": + self._syncing = True + self._spin.setValue(value) + self._syncing = False + + def _set_state(self, state: str): + if self._spin.property("movestate") != state: + self._spin.setProperty("movestate", state) + self._spin.style().unpolish(self._spin) + self._spin.style().polish(self._spin) + self._state = state + self._button.setEnabled(state == "pending") + + class MotorMoveGroup(QObject): # {name: target} for every box that was pending when Move was clicked applied = Signal(dict) diff --git a/src/aare/gui/widgets/number_line_edit.py b/src/aare/gui/widgets/number_line_edit.py index 3a0de0db..835070f0 100644 --- a/src/aare/gui/widgets/number_line_edit.py +++ b/src/aare/gui/widgets/number_line_edit.py @@ -6,16 +6,32 @@ from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QLineEdit, QWidget class NumberLineEdit(QLineEdit): """Colors are centralized: the per-theme INPUT rules in styles.py key on the :read-only pseudo-class and the "invalid" dynamic property set here — - no inline stylesheets, so both themes restyle these fields.""" + no inline stylesheets, so both themes restyle these fields. + + track_pending=True additionally shows the "movestate" pending color (same + QSS as the motor move boxes) while the typed text differs from the last + committed value; Enter/focus-out commits and clears it. Opt-in because + MotorMoveGroup owns the movestate property on the boxes it registers.""" newValue = Signal(float) def __init__( - self, min_val: float, max_val: float, default: float = 0.0, decimals: int = 2, parent=None + self, + min_val: float, + max_val: float, + default: float = 0.0, + decimals: int = 2, + parent=None, + track_pending: bool = False, ): super().__init__(parent) self._read_only: bool = False self._is_valid: bool = True + self._track_pending: bool = track_pending + self._applied_value: float = default + if track_pending: + # textEdited is user-only: programmatic setText never marks pending. + self.textEdited.connect(self._on_pending_edit) # Use a QDoubleValidator to only allow valid floating point numbers. # Named range_validator: plain "validator" would shadow @@ -49,6 +65,28 @@ class NumberLineEdit(QLineEdit): self.style().unpolish(self) self.style().polish(self) + def _set_pending(self, pending: bool) -> None: + if not self._track_pending: + return + state = "pending" if pending else "" + if self.property("movestate") == state: + return + self.setProperty("movestate", state) + self.style().unpolish(self) + self.style().polish(self) + + @Slot(str) + def _on_pending_edit(self, text: str): + try: + value = float(text) + except ValueError: + # incomplete entry ("", "-", "1e"): pending until it parses + self._set_pending(True) + return + # string compare so "200" matches an applied "200.00"; typing back the + # committed value cancels the pending state (same rule as MotorMoveGroup) + self._set_pending(self.to_string(value) != self.to_string(self._applied_value)) + @Slot(str) def on_text_changed(self, text: str): # when text changes check validation and change the colour of the line edit @@ -58,6 +96,8 @@ class NumberLineEdit(QLineEdit): @Slot() def on_editing_finished(self): val = float(self.text()) + self._applied_value = val + self._set_pending(False) self.newValue.emit(val) self.saved_value = self.saved_value @@ -68,10 +108,7 @@ class NumberLineEdit(QLineEdit): @Slot(float) def update_value(self, val: float): if abs(val - self.saved_value) > 0.001: - self.blockSignals(True) - self.saved_value = val - self.setText(self.to_string(val)) - self.blockSignals(False) + self.force_update_value(val) def force_update_value(self, val: float): # Always update text and saved_value @@ -79,6 +116,9 @@ class NumberLineEdit(QLineEdit): self.saved_value = val self.setText(self.to_string(val)) self.blockSignals(False) + # programmatic rewrite is a commit: the shown value IS the applied one + self._applied_value = val + self._set_pending(False) @Slot(float, float) def update_limits(self, min_val: float, max_val: float): @@ -272,7 +312,9 @@ class DbOverrideLineEdit(QWidget): self._source = self.SOURCE_DB self._busy = False - self.editor = NumberLineEdit(min_val, max_val, default, decimals, self) + # pending color while typing: every DbOverride field lives in the + # Experiment configuration group, which opted into the feedback + self.editor = NumberLineEdit(min_val, max_val, default, decimals, self, track_pending=True) self.editor.newValue.connect(self._on_editor_value) layout = QHBoxLayout(self) diff --git a/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py index 565e9402..30d093c4 100644 --- a/tests/unit/gui/test_data_collection_settings.py +++ b/tests/unit/gui/test_data_collection_settings.py @@ -11,12 +11,13 @@ import pytest from aarecommon.math.coordinate import Coordinate, SmargonCoordinate from aarecommon.math.diffraction_geometry import DiffractionGeometry from aarecommon.models.models import SampleGeometryModel +from PySide6.QtCore import Qt from aare.gui.panels.data_collection_settings import DataCollectionSettings from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager -from aare.gui.widgets.number_line_edit import DbOverrideLineEdit +from aare.gui.widgets.number_line_edit import DbOverrideLineEdit, NumberLineEdit def _edit(field: DbOverrideLineEdit, text: str): @@ -267,3 +268,112 @@ def test_auto_center_fires_on_mount_only_when_armed(settings_panel): settings_panel._track_sample(None) settings_panel._track_sample(4) assert len(clicks) == 3 + + +# --------------------------------------------------------------------------- +# Pending color while typing (movestate property, committed on Enter/focus-out) +# --------------------------------------------------------------------------- + + +def _type(qtbot, box, text): + box.clear() + qtbot.keyClicks(box, text) + + +@pytest.fixture +def tracked_box(qtbot): + box = NumberLineEdit(-720, 720, default=100.0, decimals=2, track_pending=True) + qtbot.addWidget(box) + return box + + +def test_typing_marks_pending_and_enter_commits(tracked_box, qtbot): + seen = [] + tracked_box.newValue.connect(seen.append) + _type(qtbot, tracked_box, "300") + assert tracked_box.property("movestate") == "pending" + assert not seen # nothing is applied until Enter/focus-out + qtbot.keyClick(tracked_box, Qt.Key.Key_Return) + assert seen == [300.0] + assert tracked_box.property("movestate") == "" + + +def test_typing_back_the_applied_value_cancels_pending(tracked_box, qtbot): + _type(qtbot, tracked_box, "300") + qtbot.keyClick(tracked_box, Qt.Key.Key_Return) + _type(qtbot, tracked_box, "300") # same as what is applied now + assert tracked_box.property("movestate") == "" + + +def test_incomplete_entry_is_pending(tracked_box, qtbot): + _type(qtbot, tracked_box, "-") # not a number (yet) + assert tracked_box.property("movestate") == "pending" + + +def test_untracked_box_never_touches_movestate(qtbot): + # MotorMoveGroup owns movestate on the boxes it registers; the default + # NumberLineEdit must therefore stay away from the property entirely. + box = NumberLineEdit(-720, 720, default=100.0, decimals=2) + qtbot.addWidget(box) + _type(qtbot, box, "300") + assert box.property("movestate") is None + + +def test_programmatic_update_clears_pending(tracked_box, qtbot): + _type(qtbot, tracked_box, "300") + tracked_box.force_update_value(120.0) + assert tracked_box.property("movestate") == "" + _type(qtbot, tracked_box, "300") + tracked_box.update_value(500.0) + assert tracked_box.property("movestate") == "" + + +def test_db_override_typing_pending_until_commit(qapp, qtbot): + w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2) + qtbot.addWidget(w) + _type(qtbot, w.editor, "300") + assert w.editor.property("movestate") == "pending" + qtbot.keyClick(w.editor, Qt.Key.Key_Return) + assert w.editor.property("movestate") == "" + assert w.value == 300.0 + assert w.source() == DbOverrideLineEdit.SOURCE_MINE + + +def test_db_override_source_toggle_clears_pending(qapp, qtbot): + w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2) + qtbot.addWidget(w) + _type(qtbot, w.editor, "300") # typing, no commit + assert w.editor.property("movestate") == "pending" + # a panel refresh (radio toggle, DB push) discards the uncommitted text + w.set_source(DbOverrideLineEdit.SOURCE_DB, emit=False) + assert w.editor.property("movestate") == "" + assert w.value == 200.0 + + +def test_energy_spin_motor_move_semantics(settings_panel, daq_status_factory): + # same placeholder limits as the Beamline setup row (shared constants) + assert settings_panel.energy_spin.minimum() == 4.0 + assert settings_panel.energy_spin.maximum() == 20.0 + assert "Beamline Scientist" in settings_panel.energy_spin.toolTip() + + # Same MotorMoveGroup wiring as the Beamline setup row (full state walk + # tested there); here: stage -> pending, button sends -> moving, readback + # arrival -> neutral. + box = settings_panel.energy_spin # movestate sits on the spin itself + assert not settings_panel.change_energy_button.isEnabled() + + settings_panel.update_daq_status(daq_status_factory()) # readback 12.0 keV + assert settings_panel.energy_spin.value() == 12.0 + + settings_panel.energy_spin.setValue(12.4) + assert box.property("movestate") == "pending" + assert settings_panel.change_energy_button.isEnabled() + + sent = [] + settings_panel.change_energy.connect(sent.append) + settings_panel.change_energy_button.click() + assert sent and sent[-1] == pytest.approx(12400.0) + assert box.property("movestate") == "moving" + + settings_panel._energy_state.update_actual(12.3995) + assert box.property("movestate") == "" diff --git a/tests/unit/gui/test_log_panel.py b/tests/unit/gui/test_log_panel.py index 962ad6df..8b8df096 100644 --- a/tests/unit/gui/test_log_panel.py +++ b/tests/unit/gui/test_log_panel.py @@ -29,3 +29,14 @@ def test_notification_requests_reveal(qtbot): with qtbot.waitSignal(panel.reveal_requested, timeout=1000): panel.show_notification(title="Boom", message="it broke") assert panel.notification._title.text() == "Boom" + + +def test_sticky_notification_is_dismissable(qtbot): + # Sticky means "no auto-clear timer", not "undismissable": the Clear + # button must stay so users can get rid of stale warnings by hand. + panel = LogPanel() + qtbot.addWidget(panel) + panel.show_notification(title="Warn", message="stuck", level="warning", sticky=True) + assert not panel.notification._clear_button.isHidden() + panel.notification._clear_button.click() + assert not panel.notification.isVisible() diff --git a/tests/unit/gui/test_monochromator_panel.py b/tests/unit/gui/test_monochromator_panel.py index dabd2d2a..3785eaeb 100644 --- a/tests/unit/gui/test_monochromator_panel.py +++ b/tests/unit/gui/test_monochromator_panel.py @@ -1,3 +1,5 @@ +import pytest + from aare.gui.panels.monochromator_panel import MonochromatorPanel @@ -9,12 +11,6 @@ def test_current_energy_readout(qtbot, daq_status_factory): panel.update_daq_status(status) assert panel.current_energy_label.text() == "12.000 keV / 1.0332 Å" - # 0.0 is the server's detector-unavailable fallback; the wavelength - # property divides by energy, so the readout must not touch it. - status.diffraction = status.diffraction.model_copy(update={"energy_keV": 0.0}) - panel.update_daq_status(status) - assert panel.current_energy_label.text() == "— / —" - def test_fast_shutter_row(qtbot, daq_status_factory): panel = MonochromatorPanel() @@ -36,3 +32,58 @@ def test_fast_shutter_row(qtbot, daq_status_factory): panel.open_shutter_button.click() with qtbot.waitSignal(panel.close_shutter, timeout=1000): panel.close_shutter_button.click() + + +def test_energy_spin_placeholder_limits(qtbot): + # 4-20 keV is a placeholder pending Beamline Scientist confirmation; the + # tooltip must say so because nothing downstream validates the request. + panel = MonochromatorPanel() + qtbot.addWidget(panel) + assert panel.energy_spin.minimum() == 4.0 + assert panel.energy_spin.maximum() == 20.0 + assert "Beamline Scientist" in panel.energy_spin.toolTip() + + +def test_energy_spin_motor_move_semantics(qtbot, daq_status_factory): + # Same protection UX as MotorMoveGroup: the spin stages, only the button + # sends, and the color walks neutral -> pending -> moving -> neutral. + panel = MonochromatorPanel() + qtbot.addWidget(panel) + box = panel.energy_spin # movestate sits on the spin, not its inner edit + sent = [] + panel.change_energy.connect(sent.append) + assert not panel.change_energy_button.isEnabled() # nothing staged yet + + status = daq_status_factory() + panel.update_daq_status(status) # readback 12.0 keV syncs the neutral spin + assert panel.energy_spin.value() == 12.0 + assert not box.property("movestate") + + panel.energy_spin.setValue(12.4) # user staging + assert box.property("movestate") == "pending" + assert panel.change_energy_button.isEnabled() + + panel.energy_spin.setValue(12.0) # back to the readback cancels + assert box.property("movestate") == "" + assert not panel.change_energy_button.isEnabled() + + panel.energy_spin.setValue(12.4) + panel.change_energy_button.click() + assert sent and sent[-1] == pytest.approx(12400.0) # keV -> eV on emit + assert box.property("movestate") == "moving" + assert not panel.change_energy_button.isEnabled() + + # travelling: the spin keeps showing the target, not the readback + status.diffraction = status.diffraction.model_copy(update={"energy_keV": 12.1}) + panel.update_daq_status(status) + assert panel.energy_spin.value() == 12.4 + assert box.property("movestate") == "moving" + + # arrival within tol returns to neutral and readback tracking resumes + # 12.3995 sits safely inside tol (the 12.399 boundary loses to float + # error: 12.4-12.399 > 0.001); the spin then rounds to its 3 decimals, + # hence the loose abs window on the value check. + status.diffraction = status.diffraction.model_copy(update={"energy_keV": 12.3995}) + panel.update_daq_status(status) + assert box.property("movestate") == "" + assert panel.energy_spin.value() == pytest.approx(12.3995, abs=1e-3) diff --git a/tests/unit/gui/test_motor_move_group.py b/tests/unit/gui/test_motor_move_group.py index 0eee1b70..fac30cf6 100644 --- a/tests/unit/gui/test_motor_move_group.py +++ b/tests/unit/gui/test_motor_move_group.py @@ -92,3 +92,49 @@ def test_update_limits_reranges_box(chi, qtbot): assert box.range_validator.bottom() == -1.0 assert box.range_validator.top() == 50.0 assert "50" in box.toolTip() + + +def test_spin_move_state_colors_actually_render(qtbot): + # Regression: movestate used to sit on the spin's inner QLineEdit, which + # the theme paints transparent — the property asserts passed while no + # color ever showed. Sample real pixels through the theme stylesheet. + from PySide6.QtGui import QColor + from PySide6.QtWidgets import QDoubleSpinBox, QPushButton + + from aare.gui.styles import ( + INPUT_MOVING_BG, + INPUT_PENDING_BG, + THEME_SUNRISE, + build_app_stylesheet, + ) + from aare.gui.widgets.motor_move_group import SpinMoveState + + spin = QDoubleSpinBox() + spin.setRange(4.0, 20.0) + spin.setStyleSheet(build_app_stylesheet(THEME_SUNRISE)) + button = QPushButton() + qtbot.addWidget(spin) + qtbot.addWidget(button) + state = SpinMoveState(spin, button, tol=0.01) + spin.resize(140, 24) + spin.show() + + def value_area_color(): + # Sample inside the left padding (1px border + 6px QSS padding), not + # the text area: glyph positions are font-dependent and on CI's Linux + # fonts x=20 lands on a digit of "12.40", returning an antialiased + # glyph/background blend instead of the plain background color. + img = spin.grab().toImage() + return img.pixelColor(4, img.height() // 2) + + state.update_actual(12.0) + neutral = value_area_color() + + spin.setValue(12.4) # user staging + assert value_area_color() == QColor(INPUT_PENDING_BG) + + button.click() + assert value_area_color() == QColor(INPUT_MOVING_BG) + + state.update_actual(12.398) # arrived within tol + assert value_area_color() == neutral diff --git a/uv.lock b/uv.lock index e52d0b16..d529cb74 100644 --- a/uv.lock +++ b/uv.lock @@ -31,7 +31,7 @@ wheels = [ [[package]] name = "aaredaq" -version = "0.12.5" +version = "0.13.4" source = { editable = "." } dependencies = [ { name = "aarecommon" },