Feat/style input box #162

Merged
duan_j merged 13 commits from feat/style-input-box into main 2026-08-24 19:26:49 +02:00
17 changed files with 418 additions and 41 deletions
+3
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+1 -1
View File
@@ -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)
@@ -19,11 +19,18 @@ from PySide6.QtWidgets import (
from aare.gui.panels.file_path_panel import FilePathPanel
from aare.gui.panels.fluorescence_data_collection import FluorescenceDataCollectionPanel
from aare.gui.panels.manual_sample_panel import ManualSamplePanel
from aare.gui.panels.monochromator_panel import (
ENERGY_AT_TARGET_TOL_KEV,
ENERGY_MAX_KEV,
ENERGY_MIN_KEV,
ENERGY_RANGE_TIP,
)
from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel
from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel
from aare.gui.panels.smart_rotation_panel import SimpleRotationSettingsPanel
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager
from aare.gui.styles import BANNER_TAB_GAP
from aare.gui.widgets.motor_move_group import SpinMoveState
from aare.gui.widgets.title_label import TitleLabel, tighten_column
@@ -111,11 +118,18 @@ class DataCollectionSettings(QFrame):
# energy without leaving the experiment configuration.
self.energy_spin = QDoubleSpinBox(parent=self)
self.energy_spin.setDecimals(3)
self.energy_spin.setRange(1.0, 30.0)
self.energy_spin.setRange(ENERGY_MIN_KEV, ENERGY_MAX_KEV)
self.energy_spin.setToolTip(ENERGY_RANGE_TIP)
self.energy_spin.setSingleStep(0.1)
self.energy_spin.setValue(12.0)
self.change_energy_button = QPushButton("Change Energy", parent=self)
self.change_energy_button.clicked.connect(self._emit_change_energy)
# MotorMoveGroup semantics for the button-gated spin, same as the
# Beamline setup row: neutral tracks the readback, edit stages
# pending, the click turns moving until arrival.
self._energy_state = SpinMoveState(
self.energy_spin, self.change_energy_button, tol=ENERGY_AT_TARGET_TOL_KEV, parent=self
)
energy_row = QWidget(self)
energy_layout = QHBoxLayout(energy_row)
energy_layout.setContentsMargins(0, 0, 0, 0)
@@ -216,6 +230,8 @@ class DataCollectionSettings(QFrame):
text = "— / —"
else:
text = f"{energy:.3f} keV / {s.diffraction.wavelength_angstrom:.4f} Å"
# 0.0 stays out: syncing the fallback would clamp the spin to min
self._energy_state.update_actual(energy)
# Guarded: runs per DAQ tick (2 Hz), skip the repaint when unchanged.
if self.current_energy_label.text() != text:
self.current_energy_label.setText(text)
@@ -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)
+2 -1
View File
@@ -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("")
+23 -7
View File
@@ -3,8 +3,22 @@ from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QDoubleSpinBox, QGridLayout, QLabel, QPushButton, QWidget
from aare.gui.styles import THEME_SUNRISE, status_colors
from aare.gui.widgets.motor_move_group import SpinMoveState
from aare.gui.widgets.title_label import TitleLabel
# TODO: placeholder range (was an arbitrary 1-30; nothing downstream validates —
# daq.change_energy forwards straight to bec) — confirm the real monochromator
# limits with the Beamline Scientist. Shared by both Set Energy rows.
ENERGY_MIN_KEV = 4.0
ENERGY_MAX_KEV = 20.0
# "arrived" window for the moving->neutral transition; per-hardware knob like
# MotorMoveGroup's tol (the mono never lands exactly on the setpoint)
ENERGY_AT_TARGET_TOL_KEV = 0.001
ENERGY_RANGE_TIP = (
f"Minimum: {ENERGY_MIN_KEV:.3f} keV\nMaximum: {ENERGY_MAX_KEV:.3f} keV\n"
"Range to be confirmed with Beamline Scientist"
)
class MonochromatorPanel(QWidget):
mono_pitch_scan = Signal()
@@ -43,7 +57,8 @@ class MonochromatorPanel(QWidget):
self.energy_spin = QDoubleSpinBox(parent=self)
self.energy_spin.setDecimals(3)
self.energy_spin.setRange(1.0, 30.0)
self.energy_spin.setRange(ENERGY_MIN_KEV, ENERGY_MAX_KEV)
self.energy_spin.setToolTip(ENERGY_RANGE_TIP)
self.energy_spin.setSingleStep(0.1)
self.energy_spin.setValue(12.0)
grid_layout.addWidget(self.energy_spin, 3, 1)
@@ -51,6 +66,11 @@ class MonochromatorPanel(QWidget):
self.change_energy_button = QPushButton("Change Energy", parent=self)
self.change_energy_button.clicked.connect(self._emit_change_energy)
grid_layout.addWidget(self.change_energy_button, 3, 2)
# MotorMoveGroup semantics for the button-gated spin: neutral tracks
# the readback, edit stages pending, click turns moving until arrival.
self._energy_state = SpinMoveState(
self.energy_spin, self.change_energy_button, tol=ENERGY_AT_TARGET_TOL_KEV, parent=self
)
# Fast shutter row: status left, Open/Close buttons right — same
# rich-text scheme as the status bar flag so the two readouts match.
@@ -109,12 +129,8 @@ class MonochromatorPanel(QWidget):
@Slot(DAQStatusModel)
def update_daq_status(self, status: DAQStatusModel):
energy = status.diffraction.energy_keV
# 0.0 is the server's detector-unavailable fallback, and the
# wavelength property divides by it — guard before touching it.
if not energy:
text = "— / —"
else:
text = f"{energy:.3f} keV / {status.diffraction.wavelength_angstrom:.4f} Å"
text = f"{energy:.3f} keV / {status.diffraction.wavelength_angstrom:.4f} Å"
self._energy_state.update_actual(energy)
# Guarded: runs per DAQ tick (2 Hz), skip the repaint when unchanged.
if self.current_energy_label.text() != text:
self.current_energy_label.setText(text)
@@ -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)
+8 -5
View File
@@ -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;
}
+63 -1
View File
@@ -17,11 +17,73 @@ not registered keep their old behavior.
"""
from PySide6.QtCore import QEvent, QObject, QPoint, Qt, Signal, Slot
from PySide6.QtWidgets import QPushButton, QToolTip
from PySide6.QtWidgets import QDoubleSpinBox, QPushButton, QToolTip
from aare.gui.widgets.number_line_edit import NumberLineEdit
class SpinMoveState(QObject):
"""MotorMoveGroup's state machine for a single QDoubleSpinBox whose apply
gate is a button (the Set Energy rows): neutral tracks the readback, a
user edit stages pending (orange) and enables the button, the click turns
moving (green), and arrival within tol returns to neutral. The movestate
property sits on the spinbox itself — its inner QLineEdit is transparent
by theme design, so coloring it shows nothing. The caller keeps its own
clicked connection for the actual send; this object only tracks state."""
def __init__(self, spin: QDoubleSpinBox, button: QPushButton, tol: float, parent=None):
super().__init__(parent)
self._spin = spin
self._button = button
# "at target" window: the mono never lands exactly on the setpoint
self._tol = tol
self._state = "" # "" (neutral) | "pending" | "moving"
self._target = 0.0
self._actual: float | None = None
# valueChanged fires for our own readback sync too; the flag is how a
# user edit is told apart (a spinbox has no textEdited-only signal for
# arrows/wheel).
self._syncing = False
spin.valueChanged.connect(self._on_value_changed)
button.clicked.connect(self._on_applied)
button.setEnabled(False)
@Slot(float)
def _on_value_changed(self, value: float):
if self._syncing:
return
# editing back to the current readback cancels the pending change
if self._actual is not None and abs(value - self._actual) <= self._tol:
self._set_state("")
else:
self._set_state("pending")
@Slot()
def _on_applied(self):
self._target = self._spin.value()
self._set_state("moving")
@Slot(float)
def update_actual(self, value: float):
"""Feed the readback. Neutral spins track it; pending/moving keep
showing the user's target until the move completes."""
self._actual = value
if self._state == "moving" and abs(value - self._target) <= self._tol:
self._set_state("")
if self._state == "":
self._syncing = True
self._spin.setValue(value)
self._syncing = False
def _set_state(self, state: str):
if self._spin.property("movestate") != state:
self._spin.setProperty("movestate", state)
self._spin.style().unpolish(self._spin)
self._spin.style().polish(self._spin)
self._state = state
self._button.setEnabled(state == "pending")
class MotorMoveGroup(QObject):
# {name: target} for every box that was pending when Move was clicked
applied = Signal(dict)
+49 -7
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
@@ -68,10 +108,7 @@ class NumberLineEdit(QLineEdit):
@Slot(float)
def update_value(self, val: float):
if abs(val - self.saved_value) > 0.001:
self.blockSignals(True)
self.saved_value = val
self.setText(self.to_string(val))
self.blockSignals(False)
self.force_update_value(val)
def force_update_value(self, val: float):
# Always update text and saved_value
@@ -79,6 +116,9 @@ class NumberLineEdit(QLineEdit):
self.saved_value = val
self.setText(self.to_string(val))
self.blockSignals(False)
# programmatic rewrite is a commit: the shown value IS the applied one
self._applied_value = val
self._set_pending(False)
@Slot(float, float)
def update_limits(self, min_val: float, max_val: float):
@@ -272,7 +312,9 @@ class DbOverrideLineEdit(QWidget):
self._source = self.SOURCE_DB
self._busy = False
self.editor = NumberLineEdit(min_val, max_val, default, decimals, self)
# pending color while typing: every DbOverride field lives in the
# Experiment configuration group, which opted into the feedback
self.editor = NumberLineEdit(min_val, max_val, default, decimals, self, track_pending=True)
self.editor.newValue.connect(self._on_editor_value)
layout = QHBoxLayout(self)
+111 -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,112 @@ def test_auto_center_fires_on_mount_only_when_armed(settings_panel):
settings_panel._track_sample(None)
settings_panel._track_sample(4)
assert len(clicks) == 3
# ---------------------------------------------------------------------------
# Pending color while typing (movestate property, committed on Enter/focus-out)
# ---------------------------------------------------------------------------
def _type(qtbot, box, text):
box.clear()
qtbot.keyClicks(box, text)
@pytest.fixture
def tracked_box(qtbot):
box = NumberLineEdit(-720, 720, default=100.0, decimals=2, track_pending=True)
qtbot.addWidget(box)
return box
def test_typing_marks_pending_and_enter_commits(tracked_box, qtbot):
seen = []
tracked_box.newValue.connect(seen.append)
_type(qtbot, tracked_box, "300")
assert tracked_box.property("movestate") == "pending"
assert not seen # nothing is applied until Enter/focus-out
qtbot.keyClick(tracked_box, Qt.Key.Key_Return)
assert seen == [300.0]
assert tracked_box.property("movestate") == ""
def test_typing_back_the_applied_value_cancels_pending(tracked_box, qtbot):
_type(qtbot, tracked_box, "300")
qtbot.keyClick(tracked_box, Qt.Key.Key_Return)
_type(qtbot, tracked_box, "300") # same as what is applied now
assert tracked_box.property("movestate") == ""
def test_incomplete_entry_is_pending(tracked_box, qtbot):
_type(qtbot, tracked_box, "-") # not a number (yet)
assert tracked_box.property("movestate") == "pending"
def test_untracked_box_never_touches_movestate(qtbot):
# MotorMoveGroup owns movestate on the boxes it registers; the default
# NumberLineEdit must therefore stay away from the property entirely.
box = NumberLineEdit(-720, 720, default=100.0, decimals=2)
qtbot.addWidget(box)
_type(qtbot, box, "300")
assert box.property("movestate") is None
def test_programmatic_update_clears_pending(tracked_box, qtbot):
_type(qtbot, tracked_box, "300")
tracked_box.force_update_value(120.0)
assert tracked_box.property("movestate") == ""
_type(qtbot, tracked_box, "300")
tracked_box.update_value(500.0)
assert tracked_box.property("movestate") == ""
def test_db_override_typing_pending_until_commit(qapp, qtbot):
w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2)
qtbot.addWidget(w)
_type(qtbot, w.editor, "300")
assert w.editor.property("movestate") == "pending"
qtbot.keyClick(w.editor, Qt.Key.Key_Return)
assert w.editor.property("movestate") == ""
assert w.value == 300.0
assert w.source() == DbOverrideLineEdit.SOURCE_MINE
def test_db_override_source_toggle_clears_pending(qapp, qtbot):
w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2)
qtbot.addWidget(w)
_type(qtbot, w.editor, "300") # typing, no commit
assert w.editor.property("movestate") == "pending"
# a panel refresh (radio toggle, DB push) discards the uncommitted text
w.set_source(DbOverrideLineEdit.SOURCE_DB, emit=False)
assert w.editor.property("movestate") == ""
assert w.value == 200.0
def test_energy_spin_motor_move_semantics(settings_panel, daq_status_factory):
# same placeholder limits as the Beamline setup row (shared constants)
assert settings_panel.energy_spin.minimum() == 4.0
assert settings_panel.energy_spin.maximum() == 20.0
assert "Beamline Scientist" in settings_panel.energy_spin.toolTip()
# Same MotorMoveGroup wiring as the Beamline setup row (full state walk
# tested there); here: stage -> pending, button sends -> moving, readback
# arrival -> neutral.
box = settings_panel.energy_spin # movestate sits on the spin itself
assert not settings_panel.change_energy_button.isEnabled()
settings_panel.update_daq_status(daq_status_factory()) # readback 12.0 keV
assert settings_panel.energy_spin.value() == 12.0
settings_panel.energy_spin.setValue(12.4)
assert box.property("movestate") == "pending"
assert settings_panel.change_energy_button.isEnabled()
sent = []
settings_panel.change_energy.connect(sent.append)
settings_panel.change_energy_button.click()
assert sent and sent[-1] == pytest.approx(12400.0)
assert box.property("movestate") == "moving"
settings_panel._energy_state.update_actual(12.3995)
assert box.property("movestate") == ""
+11
View File
@@ -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()
+57 -6
View File
@@ -1,3 +1,5 @@
import pytest
from aare.gui.panels.monochromator_panel import MonochromatorPanel
@@ -9,12 +11,6 @@ def test_current_energy_readout(qtbot, daq_status_factory):
panel.update_daq_status(status)
assert panel.current_energy_label.text() == "12.000 keV / 1.0332 Å"
# 0.0 is the server's detector-unavailable fallback; the wavelength
# property divides by energy, so the readout must not touch it.
status.diffraction = status.diffraction.model_copy(update={"energy_keV": 0.0})
panel.update_daq_status(status)
assert panel.current_energy_label.text() == "— / —"
def test_fast_shutter_row(qtbot, daq_status_factory):
panel = MonochromatorPanel()
@@ -36,3 +32,58 @@ def test_fast_shutter_row(qtbot, daq_status_factory):
panel.open_shutter_button.click()
with qtbot.waitSignal(panel.close_shutter, timeout=1000):
panel.close_shutter_button.click()
def test_energy_spin_placeholder_limits(qtbot):
# 4-20 keV is a placeholder pending Beamline Scientist confirmation; the
# tooltip must say so because nothing downstream validates the request.
panel = MonochromatorPanel()
qtbot.addWidget(panel)
assert panel.energy_spin.minimum() == 4.0
assert panel.energy_spin.maximum() == 20.0
assert "Beamline Scientist" in panel.energy_spin.toolTip()
def test_energy_spin_motor_move_semantics(qtbot, daq_status_factory):
# Same protection UX as MotorMoveGroup: the spin stages, only the button
# sends, and the color walks neutral -> pending -> moving -> neutral.
panel = MonochromatorPanel()
qtbot.addWidget(panel)
box = panel.energy_spin # movestate sits on the spin, not its inner edit
sent = []
panel.change_energy.connect(sent.append)
assert not panel.change_energy_button.isEnabled() # nothing staged yet
status = daq_status_factory()
panel.update_daq_status(status) # readback 12.0 keV syncs the neutral spin
assert panel.energy_spin.value() == 12.0
assert not box.property("movestate")
panel.energy_spin.setValue(12.4) # user staging
assert box.property("movestate") == "pending"
assert panel.change_energy_button.isEnabled()
panel.energy_spin.setValue(12.0) # back to the readback cancels
assert box.property("movestate") == ""
assert not panel.change_energy_button.isEnabled()
panel.energy_spin.setValue(12.4)
panel.change_energy_button.click()
assert sent and sent[-1] == pytest.approx(12400.0) # keV -> eV on emit
assert box.property("movestate") == "moving"
assert not panel.change_energy_button.isEnabled()
# travelling: the spin keeps showing the target, not the readback
status.diffraction = status.diffraction.model_copy(update={"energy_keV": 12.1})
panel.update_daq_status(status)
assert panel.energy_spin.value() == 12.4
assert box.property("movestate") == "moving"
# arrival within tol returns to neutral and readback tracking resumes
# 12.3995 sits safely inside tol (the 12.399 boundary loses to float
# error: 12.4-12.399 > 0.001); the spin then rounds to its 3 decimals,
# hence the loose abs window on the value check.
status.diffraction = status.diffraction.model_copy(update={"energy_keV": 12.3995})
panel.update_daq_status(status)
assert box.property("movestate") == ""
assert panel.energy_spin.value() == pytest.approx(12.3995, abs=1e-3)
+46
View File
@@ -92,3 +92,49 @@ def test_update_limits_reranges_box(chi, qtbot):
assert box.range_validator.bottom() == -1.0
assert box.range_validator.top() == 50.0
assert "50" in box.toolTip()
def test_spin_move_state_colors_actually_render(qtbot):
# Regression: movestate used to sit on the spin's inner QLineEdit, which
# the theme paints transparent — the property asserts passed while no
# color ever showed. Sample real pixels through the theme stylesheet.
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QDoubleSpinBox, QPushButton
from aare.gui.styles import (
INPUT_MOVING_BG,
INPUT_PENDING_BG,
THEME_SUNRISE,
build_app_stylesheet,
)
from aare.gui.widgets.motor_move_group import SpinMoveState
spin = QDoubleSpinBox()
spin.setRange(4.0, 20.0)
spin.setStyleSheet(build_app_stylesheet(THEME_SUNRISE))
button = QPushButton()
qtbot.addWidget(spin)
qtbot.addWidget(button)
state = SpinMoveState(spin, button, tol=0.01)
spin.resize(140, 24)
spin.show()
def value_area_color():
# Sample inside the left padding (1px border + 6px QSS padding), not
# the text area: glyph positions are font-dependent and on CI's Linux
# fonts x=20 lands on a digit of "12.40", returning an antialiased
# glyph/background blend instead of the plain background color.
img = spin.grab().toImage()
return img.pixelColor(4, img.height() // 2)
state.update_actual(12.0)
neutral = value_area_color()
spin.setValue(12.4) # user staging
assert value_area_color() == QColor(INPUT_PENDING_BG)
button.click()
assert value_area_color() == QColor(INPUT_MOVING_BG)
state.update_actual(12.398) # arrived within tol
assert value_area_color() == neutral
Generated
+1 -1
View File
@@ -31,7 +31,7 @@ wheels = [
[[package]]
name = "aaredaq"
version = "0.12.5"
version = "0.13.4"
source = { editable = "." }
dependencies = [
{ name = "aarecommon" },