From d65ff27b3a96921ad29be13faa059ac96a189567 Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 20 Aug 2026 20:07:14 +0200 Subject: [PATCH 01/13] chore: sync uv.lock with 0.13.4 version bump Co-Authored-By: Claude Fable 5 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" }, -- 2.54.0 From 3e7c334abb9a518f97c990affcb59468180882a5 Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 20 Aug 2026 20:07:14 +0200 Subject: [PATCH 02/13] feat: pending color while typing in Experiment configuration values Values inside the Experiment configuration banner now show the same pending color as the motor move boxes while the typed text differs from the applied value; Enter (or focus-out) commits as before and clears it. Opt-in via NumberLineEdit(track_pending=True) because MotorMoveGroup owns the movestate property on its registered boxes; reuses the existing movestate QSS, so no new styling. The Set Energy spinbox marks pending until Change Energy is clicked, since its apply gate is the button. Co-Authored-By: Claude Fable 5 --- .../gui/panels/data_collection_settings.py | 21 ++++ .../panels/fluorescence_data_collection.py | 8 +- .../gui/panels/rotation_data_collection.py | 10 +- src/aare/gui/panels/smart_rotation_panel.py | 18 +++- src/aare/gui/widgets/number_line_edit.py | 54 +++++++++- .../unit/gui/test_data_collection_settings.py | 101 +++++++++++++++++- 6 files changed, 198 insertions(+), 14 deletions(-) diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py index cf9ad649..7266597e 100644 --- a/src/aare/gui/panels/data_collection_settings.py +++ b/src/aare/gui/panels/data_collection_settings.py @@ -114,6 +114,11 @@ class DataCollectionSettings(QFrame): self.energy_spin.setRange(1.0, 30.0) self.energy_spin.setSingleStep(0.1) self.energy_spin.setValue(12.0) + # Pending color (same movestate QSS as the value fields) from edit + # until Change Energy is clicked — this row's apply gate is the + # button, not Enter, so the color marks "not sent yet". + self._energy_sent = self.energy_spin.value() + self.energy_spin.valueChanged.connect(self._on_energy_spin_changed) self.change_energy_button = QPushButton("Change Energy", parent=self) self.change_energy_button.clicked.connect(self._emit_change_energy) energy_row = QWidget(self) @@ -195,8 +200,24 @@ class DataCollectionSettings(QFrame): page.setSizePolicy(QSizePolicy.Policy.Preferred, vertical) self._stack.adjustSize() + def _set_energy_pending(self, pending: bool): + # movestate lives on the spinbox's internal QLineEdit so the existing + # QLineEdit[movestate="pending"] theme rules match without new QSS. + box = self.energy_spin.lineEdit() + state = "pending" if pending else "" + if box.property("movestate") != state: + box.setProperty("movestate", state) + box.style().unpolish(box) + box.style().polish(box) + + @Slot(float) + def _on_energy_spin_changed(self, value: float): + self._set_energy_pending(value != self._energy_sent) + @Slot() def _emit_change_energy(self): + self._energy_sent = self.energy_spin.value() + self._set_energy_pending(False) self.change_energy.emit(float(self.energy_spin.value()) * 1000.0) @Slot() 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/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/widgets/number_line_edit.py b/src/aare/gui/widgets/number_line_edit.py index 3a0de0db..f4c1aa95 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 @@ -72,6 +112,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) def force_update_value(self, val: float): # Always update text and saved_value @@ -79,6 +122,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 +318,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..6e59b889 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,101 @@ 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_pending_until_change_energy(settings_panel): + box = settings_panel.energy_spin.lineEdit() + settings_panel.energy_spin.setValue(12.4) + assert box.property("movestate") == "pending" + + 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") == "" + + # dialing back to the last sent value clears without the button + settings_panel.energy_spin.setValue(12.5) + assert box.property("movestate") == "pending" + settings_panel.energy_spin.setValue(12.4) + assert box.property("movestate") == "" -- 2.54.0 From 225d7064c40d12651bdcf59a3e71e35a2c058b2b Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 20 Aug 2026 20:23:36 +0200 Subject: [PATCH 03/13] feat: bound Set Energy to a 4-20 keV placeholder range Both Set Energy rows allowed 1-30 keV, an arbitrary spinbox default; nothing downstream validates the request (daq.change_energy forwards straight to bec). Shared ENERGY_MIN/MAX_KEV constants now bound both spins to 4-20 keV with a tooltip flagging the range as pending Beamline Scientist confirmation. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/data_collection_settings.py | 4 +++- src/aare/gui/panels/monochromator_panel.py | 13 ++++++++++++- tests/unit/gui/test_data_collection_settings.py | 5 +++++ tests/unit/gui/test_monochromator_panel.py | 10 ++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py index 7266597e..b3a57737 100644 --- a/src/aare/gui/panels/data_collection_settings.py +++ b/src/aare/gui/panels/data_collection_settings.py @@ -19,6 +19,7 @@ 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_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 @@ -111,7 +112,8 @@ 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) # Pending color (same movestate QSS as the value fields) from edit diff --git a/src/aare/gui/panels/monochromator_panel.py b/src/aare/gui/panels/monochromator_panel.py index 58bbcf33..15710743 100644 --- a/src/aare/gui/panels/monochromator_panel.py +++ b/src/aare/gui/panels/monochromator_panel.py @@ -5,6 +5,16 @@ from PySide6.QtWidgets import QDoubleSpinBox, QGridLayout, QLabel, QPushButton, from aare.gui.styles import THEME_SUNRISE, status_colors 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 +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 +53,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) diff --git a/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py index 6e59b889..7d400c4e 100644 --- a/tests/unit/gui/test_data_collection_settings.py +++ b/tests/unit/gui/test_data_collection_settings.py @@ -351,6 +351,11 @@ def test_db_override_source_toggle_clears_pending(qapp, qtbot): def test_energy_spin_pending_until_change_energy(settings_panel): + # 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() + box = settings_panel.energy_spin.lineEdit() settings_panel.energy_spin.setValue(12.4) assert box.property("movestate") == "pending" diff --git a/tests/unit/gui/test_monochromator_panel.py b/tests/unit/gui/test_monochromator_panel.py index dabd2d2a..5c4bf1f5 100644 --- a/tests/unit/gui/test_monochromator_panel.py +++ b/tests/unit/gui/test_monochromator_panel.py @@ -36,3 +36,13 @@ 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() -- 2.54.0 From e1d11aa94904b2411cc0142d155d720d25c3cbe8 Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 20 Aug 2026 20:31:59 +0200 Subject: [PATCH 04/13] feat: MotorMoveGroup semantics for both Set Energy rows The energy setpoint applies via the Change Energy button, not Enter, so it gets the full motor state machine instead of the typing-pending color: neutral tracks the readback, a user edit stages pending (and is the only thing that enables the button), the click turns moving, and readback arrival within tol returns to neutral. New SpinMoveState in motor_move_group.py adapts the pattern to a QDoubleSpinBox+button pair (colors ride the movestate QSS via the spin's internal QLineEdit); replaces the hand-rolled pending-until-click in the exp-config row and covers the Beamline setup row that had no color at all. The server's 0.0 detector-unavailable energy is kept out of the readback feed so it cannot clamp the spin to the range minimum. Co-Authored-By: Claude Fable 5 --- .../gui/panels/data_collection_settings.py | 37 +++++------ src/aare/gui/panels/monochromator_panel.py | 11 ++++ src/aare/gui/widgets/motor_move_group.py | 65 ++++++++++++++++++- .../unit/gui/test_data_collection_settings.py | 18 +++-- tests/unit/gui/test_monochromator_panel.py | 45 +++++++++++++ 5 files changed, 147 insertions(+), 29 deletions(-) diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py index b3a57737..c9c471d2 100644 --- a/src/aare/gui/panels/data_collection_settings.py +++ b/src/aare/gui/panels/data_collection_settings.py @@ -19,12 +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_MAX_KEV, ENERGY_MIN_KEV, ENERGY_RANGE_TIP +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 @@ -116,13 +122,14 @@ class DataCollectionSettings(QFrame): self.energy_spin.setToolTip(ENERGY_RANGE_TIP) self.energy_spin.setSingleStep(0.1) self.energy_spin.setValue(12.0) - # Pending color (same movestate QSS as the value fields) from edit - # until Change Energy is clicked — this row's apply gate is the - # button, not Enter, so the color marks "not sent yet". - self._energy_sent = self.energy_spin.value() - self.energy_spin.valueChanged.connect(self._on_energy_spin_changed) 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) @@ -202,24 +209,8 @@ class DataCollectionSettings(QFrame): page.setSizePolicy(QSizePolicy.Policy.Preferred, vertical) self._stack.adjustSize() - def _set_energy_pending(self, pending: bool): - # movestate lives on the spinbox's internal QLineEdit so the existing - # QLineEdit[movestate="pending"] theme rules match without new QSS. - box = self.energy_spin.lineEdit() - state = "pending" if pending else "" - if box.property("movestate") != state: - box.setProperty("movestate", state) - box.style().unpolish(box) - box.style().polish(box) - - @Slot(float) - def _on_energy_spin_changed(self, value: float): - self._set_energy_pending(value != self._energy_sent) - @Slot() def _emit_change_energy(self): - self._energy_sent = self.energy_spin.value() - self._set_energy_pending(False) self.change_energy.emit(float(self.energy_spin.value()) * 1000.0) @Slot() @@ -239,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/monochromator_panel.py b/src/aare/gui/panels/monochromator_panel.py index 15710743..8d817f6e 100644 --- a/src/aare/gui/panels/monochromator_panel.py +++ b/src/aare/gui/panels/monochromator_panel.py @@ -3,6 +3,7 @@ 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 — @@ -10,6 +11,9 @@ from aare.gui.widgets.title_label import TitleLabel # 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.01 ENERGY_RANGE_TIP = ( f"Minimum: {ENERGY_MIN_KEV:.3f} keV\nMaximum: {ENERGY_MAX_KEV:.3f} keV\n" "Range to be confirmed with Beamline Scientist" @@ -62,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. @@ -126,6 +135,8 @@ class MonochromatorPanel(QWidget): text = "— / —" else: text = f"{energy:.3f} keV / {status.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/widgets/motor_move_group.py b/src/aare/gui/widgets/motor_move_group.py index 2248ad48..05e3c5c7 100644 --- a/src/aare/gui/widgets/motor_move_group.py +++ b/src/aare/gui/widgets/motor_move_group.py @@ -17,11 +17,74 @@ 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. Colors ride the + same movestate QSS via the spinbox's internal QLineEdit, so no new + styling. 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): + box = self._spin.lineEdit() + if box.property("movestate") != state: + box.setProperty("movestate", state) + box.style().unpolish(box) + box.style().polish(box) + 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/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py index 7d400c4e..32ac0170 100644 --- a/tests/unit/gui/test_data_collection_settings.py +++ b/tests/unit/gui/test_data_collection_settings.py @@ -350,24 +350,30 @@ def test_db_override_source_toggle_clears_pending(qapp, qtbot): assert w.value == 200.0 -def test_energy_spin_pending_until_change_energy(settings_panel): +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.lineEdit() + 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") == "" + assert box.property("movestate") == "moving" - # dialing back to the last sent value clears without the button - settings_panel.energy_spin.setValue(12.5) - assert box.property("movestate") == "pending" - settings_panel.energy_spin.setValue(12.4) + settings_panel._energy_state.update_actual(12.398) assert box.property("movestate") == "" diff --git a/tests/unit/gui/test_monochromator_panel.py b/tests/unit/gui/test_monochromator_panel.py index 5c4bf1f5..463756ab 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 @@ -46,3 +48,46 @@ def test_energy_spin_placeholder_limits(qtbot): 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.lineEdit() + 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, and the server's 0.0 + # detector-unavailable fallback must not disturb the state either + status.diffraction = status.diffraction.model_copy(update={"energy_keV": 0.0}) + 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 + status.diffraction = status.diffraction.model_copy(update={"energy_keV": 12.396}) + panel.update_daq_status(status) + assert box.property("movestate") == "" + assert panel.energy_spin.value() == pytest.approx(12.396) -- 2.54.0 From b984a36f7cc3c5fd03e16f21d4d2772b748b5894 Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 20 Aug 2026 20:47:24 +0200 Subject: [PATCH 05/13] fix: energy spin movestate colors never rendered The movestate property sat on the spinbox's inner QLineEdit, which both themes deliberately paint transparent (QAbstractSpinBox QLineEdit rule) - the state machine worked but no color ever showed, and the tests only asserted the property, not the paint. The property now lives on the spinbox itself with QAbstractSpinBox[movestate=...] added to both themes' rules, verified by a pixel-sampling regression test that grabs the rendered widget through the real stylesheet. Co-Authored-By: Claude Fable 5 --- src/aare/gui/styles.py | 13 +++--- src/aare/gui/widgets/motor_move_group.py | 17 ++++---- .../unit/gui/test_data_collection_settings.py | 2 +- tests/unit/gui/test_monochromator_panel.py | 2 +- tests/unit/gui/test_motor_move_group.py | 42 +++++++++++++++++++ 5 files changed, 60 insertions(+), 16 deletions(-) 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 05e3c5c7..3315d4b0 100644 --- a/src/aare/gui/widgets/motor_move_group.py +++ b/src/aare/gui/widgets/motor_move_group.py @@ -26,10 +26,10 @@ 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. Colors ride the - same movestate QSS via the spinbox's internal QLineEdit, so no new - styling. The caller keeps its own clicked connection for the actual send; - this object only tracks state.""" + 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) @@ -76,11 +76,10 @@ class SpinMoveState(QObject): self._syncing = False def _set_state(self, state: str): - box = self._spin.lineEdit() - if box.property("movestate") != state: - box.setProperty("movestate", state) - box.style().unpolish(box) - box.style().polish(box) + 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") diff --git a/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py index 32ac0170..51cd0eaf 100644 --- a/tests/unit/gui/test_data_collection_settings.py +++ b/tests/unit/gui/test_data_collection_settings.py @@ -359,7 +359,7 @@ def test_energy_spin_motor_move_semantics(settings_panel, daq_status_factory): # 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.lineEdit() + 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 diff --git a/tests/unit/gui/test_monochromator_panel.py b/tests/unit/gui/test_monochromator_panel.py index 463756ab..46f2a814 100644 --- a/tests/unit/gui/test_monochromator_panel.py +++ b/tests/unit/gui/test_monochromator_panel.py @@ -55,7 +55,7 @@ def test_energy_spin_motor_move_semantics(qtbot, daq_status_factory): # sends, and the color walks neutral -> pending -> moving -> neutral. panel = MonochromatorPanel() qtbot.addWidget(panel) - box = panel.energy_spin.lineEdit() + 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 diff --git a/tests/unit/gui/test_motor_move_group.py b/tests/unit/gui/test_motor_move_group.py index 0eee1b70..8f0653db 100644 --- a/tests/unit/gui/test_motor_move_group.py +++ b/tests/unit/gui/test_motor_move_group.py @@ -92,3 +92,45 @@ 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(): + img = spin.grab().toImage() + return img.pixelColor(20, 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 -- 2.54.0 From 3192fd2388f96dbeaf0fd3091f368f337f627bac Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 22 Aug 2026 02:41:12 +0200 Subject: [PATCH 06/13] style: make the banner text colored so it shows on white/black background --- src/aare/gui/graphics/aare_banner_blue.png | Bin 0 -> 33202 bytes src/aare/gui/gui.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 src/aare/gui/graphics/aare_banner_blue.png 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 0000000000000000000000000000000000000000..39dfa9c579311ed4c637d9be3390176f4c787d4c GIT binary patch literal 33202 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�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 literal 0 HcmV?d00001 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) -- 2.54.0 From a7bd46c14be06735fd195c38687b032db9a79096 Mon Sep 17 00:00:00 2001 From: Dawn Date: Sat, 22 Aug 2026 03:09:05 +0200 Subject: [PATCH 07/13] test: sample padding pixel so movestate colors survive Linux fonts x=20 sits on a digit glyph with CI's Linux fonts, so the assert compared an antialiased glyph blend against the pure background. The padding area (border 1px + padding 6px) can never contain text on any font. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0147jE48bQUTm9AqNQApT6b2 --- tests/unit/gui/test_motor_move_group.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/gui/test_motor_move_group.py b/tests/unit/gui/test_motor_move_group.py index 8f0653db..fac30cf6 100644 --- a/tests/unit/gui/test_motor_move_group.py +++ b/tests/unit/gui/test_motor_move_group.py @@ -120,8 +120,12 @@ def test_spin_move_state_colors_actually_render(qtbot): 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(20, img.height() // 2) + return img.pixelColor(4, img.height() // 2) state.update_actual(12.0) neutral = value_area_color() -- 2.54.0 From 789b2ae6e207abadc94a45efb89f6111520ea429 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 24 Aug 2026 18:19:11 +0200 Subject: [PATCH 08/13] fix: energy tolerance to 0.001 instead of 0.01 --- src/aare/gui/panels/monochromator_panel.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/aare/gui/panels/monochromator_panel.py b/src/aare/gui/panels/monochromator_panel.py index 8d817f6e..28f787e5 100644 --- a/src/aare/gui/panels/monochromator_panel.py +++ b/src/aare/gui/panels/monochromator_panel.py @@ -13,7 +13,7 @@ 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.01 +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" @@ -129,14 +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} Å" - # 0.0 stays out: syncing the fallback would clamp the spin to min - self._energy_state.update_actual(energy) + 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) -- 2.54.0 From 09f81c8392426cc3e940e885b06bd6785d720889 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 24 Aug 2026 18:19:30 +0200 Subject: [PATCH 09/13] chore: cleanup repeating code --- src/aare/gui/widgets/number_line_edit.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/aare/gui/widgets/number_line_edit.py b/src/aare/gui/widgets/number_line_edit.py index f4c1aa95..835070f0 100644 --- a/src/aare/gui/widgets/number_line_edit.py +++ b/src/aare/gui/widgets/number_line_edit.py @@ -108,13 +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) - # programmatic rewrite is a commit: the shown value IS the applied one - self._applied_value = val - self._set_pending(False) + self.force_update_value(val) def force_update_value(self, val: float): # Always update text and saved_value -- 2.54.0 From bdafc03963346490d27f343a5896609e434241c8 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 24 Aug 2026 18:21:01 +0200 Subject: [PATCH 10/13] feat: allow dismissing notification. Trial. If not good, revert this. --- src/aare/gui/panels/log_panel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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("—") -- 2.54.0 From c71676a29c73ce3a0fb07ce193f6569d17ccfdf2 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 24 Aug 2026 18:21:15 +0200 Subject: [PATCH 11/13] test: log panel --- tests/unit/gui/test_log_panel.py | 11 +++++++++++ 1 file changed, 11 insertions(+) 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() -- 2.54.0 From 855632d5b54425d0a848fa3267448dfd9942723c Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 24 Aug 2026 18:44:55 +0200 Subject: [PATCH 12/13] fix: wheel include png and svg --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) 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 -- 2.54.0 From a19cbf10815c8faa5595c6118d4d58319ae0d62c Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 24 Aug 2026 18:54:23 +0200 Subject: [PATCH 13/13] test: no need to test energy 0 and update tolerance test: update test... --- .../unit/gui/test_data_collection_settings.py | 2 +- tests/unit/gui/test_monochromator_panel.py | 18 +++++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py index 51cd0eaf..30d093c4 100644 --- a/tests/unit/gui/test_data_collection_settings.py +++ b/tests/unit/gui/test_data_collection_settings.py @@ -375,5 +375,5 @@ def test_energy_spin_motor_move_semantics(settings_panel, daq_status_factory): assert sent and sent[-1] == pytest.approx(12400.0) assert box.property("movestate") == "moving" - settings_panel._energy_state.update_actual(12.398) + settings_panel._energy_state.update_actual(12.3995) assert box.property("movestate") == "" diff --git a/tests/unit/gui/test_monochromator_panel.py b/tests/unit/gui/test_monochromator_panel.py index 46f2a814..3785eaeb 100644 --- a/tests/unit/gui/test_monochromator_panel.py +++ b/tests/unit/gui/test_monochromator_panel.py @@ -11,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() @@ -79,15 +73,17 @@ def test_energy_spin_motor_move_semantics(qtbot, daq_status_factory): assert box.property("movestate") == "moving" assert not panel.change_energy_button.isEnabled() - # travelling: the spin keeps showing the target, and the server's 0.0 - # detector-unavailable fallback must not disturb the state either - status.diffraction = status.diffraction.model_copy(update={"energy_keV": 0.0}) + # 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 - status.diffraction = status.diffraction.model_copy(update={"energy_keV": 12.396}) + # 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.396) + assert panel.energy_spin.value() == pytest.approx(12.3995, abs=1e-3) -- 2.54.0