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") == ""