Feat/style input box #162
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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("—")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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") == ""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user