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 13:40:03 +02:00
committed by duan_j
co-authored by Claude Fable 5
parent 7fdbfef06c
commit adc8514174
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)