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 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 14:03:06 +02:00
co-authored by Claude Fable 5
parent 26189533a7
commit e9449617f4
6 changed files with 198 additions and 14 deletions
@@ -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()
@@ -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)
@@ -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)
+13 -5
View File
@@ -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)
+51 -3
View File
@@ -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)
+100 -1
View File
@@ -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") == ""