GUI: dynamic signals, sample tools, fast shutter row #144
+83
-32
@@ -167,10 +167,12 @@ class _AlertBannerHost(QWidget):
|
||||
class MainWindow(QMainWindow):
|
||||
sample_geometry = Signal(SampleGeometryModel)
|
||||
|
||||
# Set lazily outside __init__ (first use guards with getattr/default);
|
||||
# declared for the basedpyright gate.
|
||||
# Class-level defaults: Qt can call overrides (showEvent, closeEvent)
|
||||
# before __init__ finishes, so these must be readable without a getattr
|
||||
# guard; also typed for the basedpyright gate.
|
||||
_session_operations_enabled: bool | None = None
|
||||
_default_dock_split_done: bool = False
|
||||
_cleanup_done: bool = False
|
||||
_pre_watch_dock_state: QByteArray | None = None
|
||||
_pre_watch_visibility: list[tuple[QWidget, bool]] | None = None
|
||||
|
||||
@@ -207,7 +209,6 @@ class MainWindow(QMainWindow):
|
||||
self._beamline_recovery_dialog = None
|
||||
self._local_contact_dialog = None
|
||||
self._controls_help_dialog = None
|
||||
self._cleanup_done = False
|
||||
self._default_window_state = None
|
||||
self._pre_automation_window_state = None
|
||||
self._pre_automation_left_column_visible = True
|
||||
@@ -241,18 +242,19 @@ class MainWindow(QMainWindow):
|
||||
self.state_manager = UIStateManager("PSI", "AareGUI")
|
||||
|
||||
# App-level, not window-level: dialogs and pop-outs get it too.
|
||||
self._clickable_cursor_filter = ClickableCursorFilter(self)
|
||||
# Installed once per process and parented to the app, NOT the window:
|
||||
# the old per-window copies stacked up (one per MainWindow the test
|
||||
# suite builds) and kept filtering every process event after their
|
||||
# parent window died — stale wrappers in the hottest Qt→Python path.
|
||||
app = QApplication.instance()
|
||||
assert app is not None
|
||||
app.installEventFilter(self._clickable_cursor_filter)
|
||||
|
||||
# Wheel safety: sliders/spin boxes/combos only react to the wheel
|
||||
# while the right mouse button is held; a bare wheel just scrolls
|
||||
# the page — it can never nudge a value or move a motor.
|
||||
self._wheel_value_guard = WheelValueGuard(self)
|
||||
app_instance = QApplication.instance()
|
||||
if app_instance is not None:
|
||||
app_instance.installEventFilter(self._wheel_value_guard)
|
||||
if not app.property("_aare_app_filters_installed"):
|
||||
app.setProperty("_aare_app_filters_installed", True)
|
||||
app.installEventFilter(ClickableCursorFilter(app))
|
||||
# Wheel safety: sliders/spin boxes/combos only react to the wheel
|
||||
# while the right mouse button is held; a bare wheel just scrolls
|
||||
# the page — it can never nudge a value or move a motor.
|
||||
app.installEventFilter(WheelValueGuard(app))
|
||||
self.viewer = JFJochDBusClient()
|
||||
|
||||
try:
|
||||
@@ -606,6 +608,23 @@ class MainWindow(QMainWindow):
|
||||
self.job_list_panel.remove_button.clicked.connect(self._remove_selected_from_queue)
|
||||
self.job_list_panel.hide()
|
||||
|
||||
# Queue controls act on the queue, whose order only the Queued chip
|
||||
# view shows — so they are live there and greyed out elsewhere. The
|
||||
# pop-out clones register themselves in _clone_automation_row. "run"
|
||||
# kind stays enabled while automation runs: pausing is always allowed.
|
||||
self._queue_action_buttons: list[tuple[QPushButton, str]] = [
|
||||
(self.job_list_panel.play_button, "run"),
|
||||
(self.job_list_panel.remove_button, "queue"),
|
||||
(self.job_list_panel.clear_button, "queue"),
|
||||
]
|
||||
self.tell_samples.status_chips.buttonClicked.connect(
|
||||
lambda _chip: self._update_queue_buttons_enabled()
|
||||
)
|
||||
self.job_list_panel.automation_running_changed.connect(
|
||||
lambda _running: self._update_queue_buttons_enabled()
|
||||
)
|
||||
self._update_queue_buttons_enabled()
|
||||
|
||||
self.sample_lists_tabs = QTabWidget()
|
||||
self.sample_lists_tabs.addTab(dewar_tab, "Dewar samples")
|
||||
self.sample_lists_tabs.addTab(self.ref_tools_panel, "Auxiliary puck")
|
||||
@@ -970,6 +989,8 @@ class MainWindow(QMainWindow):
|
||||
if self._decoded_token.staff:
|
||||
self.monochromator_panel.mono_pitch_scan.connect(self.daq.mono_pitch_scan)
|
||||
self.monochromator_panel.change_energy.connect(self.daq.change_energy)
|
||||
self.monochromator_panel.open_shutter.connect(self.daq.open_shutter)
|
||||
self.monochromator_panel.close_shutter.connect(self.daq.close_shutter)
|
||||
self.abr_tweak.abr_tweak.connect(self.daq.abr_tweak)
|
||||
self.abr_tweak.abr_save.connect(self.daq.abr_save)
|
||||
self.abr_tweak.abr_goto_meas.connect(self.daq.abr_goto_meas)
|
||||
@@ -1293,6 +1314,16 @@ class MainWindow(QMainWindow):
|
||||
sample.db_id for sample in self.job_list_panel.table_model.samples
|
||||
)
|
||||
|
||||
@Slot()
|
||||
def _update_queue_buttons_enabled(self) -> None:
|
||||
queued_view = self.tell_samples.table_model.status_filter == "queued"
|
||||
running = self.job_list_panel.is_running()
|
||||
tip = "Active in the Queued view — click the Queued chip above the table"
|
||||
for button, kind in self._queue_action_buttons:
|
||||
enabled = queued_view or (kind == "run" and running)
|
||||
button.setEnabled(enabled)
|
||||
button.setToolTip("" if enabled else tip)
|
||||
|
||||
@Slot()
|
||||
def _remove_selected_from_queue(self) -> None:
|
||||
self._unqueue_panel_selection(self.tell_samples)
|
||||
@@ -1337,6 +1368,11 @@ class MainWindow(QMainWindow):
|
||||
dewar_panel.status_chips.buttonClicked.connect(
|
||||
lambda chip: self.tell_samples.set_status_chip(chip.property("status_key"))
|
||||
)
|
||||
# Pop-out chips drive the same shared filter — regate the queue
|
||||
# buttons from here too.
|
||||
dewar_panel.status_chips.buttonClicked.connect(
|
||||
lambda _chip: self._update_queue_buttons_enabled()
|
||||
)
|
||||
dewar_panel.set_status_chip(self.tell_samples.table_model.status_filter)
|
||||
|
||||
dewar_tab = QWidget()
|
||||
@@ -1404,6 +1440,14 @@ class MainWindow(QMainWindow):
|
||||
unmount_button = QPushButton("⏏ Unmount")
|
||||
unmount_button.clicked.connect(lambda: self._on_manual_unmount_requested())
|
||||
|
||||
# Same Queued-view gating as the docked trio.
|
||||
self._queue_action_buttons += [
|
||||
(run_button, "run"),
|
||||
(remove_button, "queue"),
|
||||
(clear_button, "queue"),
|
||||
]
|
||||
self._update_queue_buttons_enabled()
|
||||
|
||||
row = QHBoxLayout()
|
||||
for button in (run_button, remove_button, clear_button, unmount_button):
|
||||
row.addWidget(button)
|
||||
@@ -1897,6 +1941,9 @@ class MainWindow(QMainWindow):
|
||||
# _apply_theme call in __init__, hence the guard.
|
||||
if hasattr(self, "status_bar"):
|
||||
self.status_bar.set_theme(self._theme_mode)
|
||||
# Shutter flag in Beamline setup — staff-only panel, hence the guard.
|
||||
if hasattr(self, "monochromator_panel"):
|
||||
self.monochromator_panel.set_theme(self._theme_mode)
|
||||
self.sample_camera.set_theme(self._theme_mode)
|
||||
if old_look is None:
|
||||
return
|
||||
@@ -2726,8 +2773,12 @@ class MainWindow(QMainWindow):
|
||||
# Busy (or the robot station) means something is physically moving:
|
||||
# show the Beamline combined view so the motion can be watched, and
|
||||
# return to the sample camera once it is done. Edge-triggered so a
|
||||
# manual tab choice survives between transitions.
|
||||
moving = bool(s.busy) or s.state == BeamlineStateEnum.RobotSampleExchange
|
||||
# manual tab choice survives between transitions. Sample alignment is
|
||||
# the exception: its busy moves ARE the alignment, and the user needs
|
||||
# to keep watching the sample camera, not the beamline view.
|
||||
moving = (
|
||||
bool(s.busy) or s.state == BeamlineStateEnum.RobotSampleExchange
|
||||
) and s.state != BeamlineStateEnum.SampleAlignment
|
||||
if moving and not self._watching_motion:
|
||||
self._watching_motion = True
|
||||
self.video_tab.setCurrentWidget(self.beamline_combined_panel)
|
||||
@@ -2992,7 +3043,7 @@ class MainWindow(QMainWindow):
|
||||
# default dock split AFTER the real (maximized) geometry exists —
|
||||
# the __init__ resizeDocks ran on the pre-show size and Qt hands the
|
||||
# scale-up surplus to the sample list, skewing 50/50 into ~80/20.
|
||||
if not getattr(self, "_default_dock_split_done", False):
|
||||
if not self._default_dock_split_done:
|
||||
self._default_dock_split_done = True
|
||||
if not self.state_manager.settings.value("main_window/state"):
|
||||
QTimer.singleShot(0, self._apply_default_dock_split)
|
||||
@@ -3041,7 +3092,7 @@ class MainWindow(QMainWindow):
|
||||
super().closeEvent(event)
|
||||
|
||||
def cleanup(self):
|
||||
if getattr(self, "_cleanup_done", False):
|
||||
if self._cleanup_done:
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -3209,21 +3260,21 @@ class MainWindow(QMainWindow):
|
||||
obj.setFloating(False)
|
||||
event.ignore()
|
||||
return True
|
||||
try:
|
||||
if event.type() in {
|
||||
QEvent.Type.MouseButtonPress,
|
||||
QEvent.Type.MouseButtonRelease,
|
||||
QEvent.Type.MouseMove,
|
||||
QEvent.Type.Wheel,
|
||||
QEvent.Type.KeyPress,
|
||||
QEvent.Type.KeyRelease,
|
||||
QEvent.Type.FocusIn,
|
||||
QEvent.Type.TouchBegin,
|
||||
QEvent.Type.TouchUpdate,
|
||||
}:
|
||||
self._mark_user_interaction()
|
||||
except Exception as e:
|
||||
logger.debug(f"GUI interaction event filter error: {e}", exc_info=True)
|
||||
# No try/except: every attribute this touches exists before the first
|
||||
# install, and the one risky call (backend report) guards itself in
|
||||
# _refresh_idle_activity.
|
||||
if event.type() in {
|
||||
QEvent.Type.MouseButtonPress,
|
||||
QEvent.Type.MouseButtonRelease,
|
||||
QEvent.Type.MouseMove,
|
||||
QEvent.Type.Wheel,
|
||||
QEvent.Type.KeyPress,
|
||||
QEvent.Type.KeyRelease,
|
||||
QEvent.Type.FocusIn,
|
||||
QEvent.Type.TouchBegin,
|
||||
QEvent.Type.TouchUpdate,
|
||||
}:
|
||||
self._mark_user_interaction()
|
||||
# getattr defaults: this filter also runs for events delivered while
|
||||
# __init__ is still building (or teardown is tearing down) the very
|
||||
# widgets it inspects — a raise here spams every event and breaks
|
||||
|
||||
@@ -40,9 +40,9 @@ def get_entry(sample: SampleShortInfo, column: int):
|
||||
elif column == 8:
|
||||
return sample.raster_count
|
||||
elif column == 9:
|
||||
return sample.rotation_count
|
||||
elif column == 10:
|
||||
return sample.screening_count
|
||||
elif column == 10:
|
||||
return sample.rotation_count
|
||||
elif column == 11:
|
||||
return sample.comment
|
||||
return ""
|
||||
@@ -70,8 +70,10 @@ class UserSampleSpreadsheet(QAbstractTableModel):
|
||||
"User",
|
||||
"Mount count",
|
||||
"Raster count",
|
||||
"Rotation count",
|
||||
# Workflow order: a sample is screened before rotation data is
|
||||
# collected, so Screening sits left of Rotation.
|
||||
"Screening count",
|
||||
"Rotation count",
|
||||
"Comment",
|
||||
]
|
||||
self.current_sample = current_sample
|
||||
@@ -89,8 +91,12 @@ class UserSampleSpreadsheet(QAbstractTableModel):
|
||||
# as the queue view). Fed from outside; the queue itself stays in the
|
||||
# SampleQueueSpreadsheet.
|
||||
self.queued_ids: set[int] = set()
|
||||
# Queue order as the engine holds it (Run pops its head): the Queued
|
||||
# chip view displays THIS order, not the header sort.
|
||||
self.queued_order: list[int] = []
|
||||
self.flagged_ids: set[int] = set()
|
||||
# None = All; otherwise "queued" | "flagged" | "measured" (chip row).
|
||||
# None = All; otherwise "queued" | "flagged" | "measured" |
|
||||
# "unmeasured" (chip row).
|
||||
self.status_filter: str | None = None
|
||||
|
||||
self._sort()
|
||||
@@ -148,7 +154,7 @@ class UserSampleSpreadsheet(QAbstractTableModel):
|
||||
return SAMPLE_STATUS_FLAGGED_BG if flagged else None
|
||||
if self.status_filter == "flagged":
|
||||
return SAMPLE_STATUS_QUEUED_BG if queued else None
|
||||
if self.status_filter == "measured":
|
||||
if self.status_filter in ("measured", "unmeasured"):
|
||||
if queued:
|
||||
return SAMPLE_STATUS_QUEUED_BG
|
||||
return SAMPLE_STATUS_FLAGGED_BG if flagged else None
|
||||
@@ -162,12 +168,15 @@ class UserSampleSpreadsheet(QAbstractTableModel):
|
||||
|
||||
@staticmethod
|
||||
def _measured(sample: SampleShortInfo) -> bool:
|
||||
# Automatic status, never relabelled by hand: a sample counts as
|
||||
# measured once its rotation count exceeds 1.
|
||||
return isinstance(sample.rotation_count, (int, float)) and sample.rotation_count > 1
|
||||
# Automatic status, never relabelled by hand: any rotation data
|
||||
# counts as measured (>= 1); unmeasured is exactly rotation count 0.
|
||||
return isinstance(sample.rotation_count, (int, float)) and sample.rotation_count >= 1
|
||||
|
||||
def set_queued_ids(self, db_ids) -> None:
|
||||
self.queued_ids = set(db_ids)
|
||||
# Callers pass the ids in queue order (main_window feeds them straight
|
||||
# from the queue model) — keep it for the Queued view's row order.
|
||||
self.queued_order = list(db_ids)
|
||||
self.queued_ids = set(self.queued_order)
|
||||
self._status_sets_changed()
|
||||
|
||||
def set_flagged(self, db_id: int, flagged: bool) -> None:
|
||||
@@ -235,6 +244,14 @@ class UserSampleSpreadsheet(QAbstractTableModel):
|
||||
|
||||
def _sort(self):
|
||||
filtered = self._apply_filter(self.samples)
|
||||
if self.status_filter == "queued":
|
||||
# The Queued view is the run order — row 1 runs next. Header
|
||||
# clicks still move the indicator but must not reorder it.
|
||||
position = {db_id: i for i, db_id in enumerate(self.queued_order)}
|
||||
self._sorted_samples = sorted(
|
||||
filtered, key=lambda row: position.get(row.db_id, len(position))
|
||||
)
|
||||
return
|
||||
if self._sort_col == 4: # Location
|
||||
self._sorted_samples = sorted(
|
||||
filtered,
|
||||
@@ -261,6 +278,9 @@ class UserSampleSpreadsheet(QAbstractTableModel):
|
||||
rows = [r for r in rows if r.db_id in self.flagged_ids]
|
||||
elif self.status_filter == "measured":
|
||||
rows = [r for r in rows if self._measured(r)]
|
||||
elif self.status_filter == "unmeasured":
|
||||
# Everything still to be done — the view to select-all and queue.
|
||||
rows = [r for r in rows if not self._measured(r)]
|
||||
|
||||
# Default filter by User using current p-group if no explicit filter set
|
||||
filters: dict[int, str] = {
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
from typing import ClassVar
|
||||
|
||||
from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel
|
||||
from PySide6.QtCore import Qt, QTimer, Signal, Slot
|
||||
from PySide6.QtCore import QByteArray, QPropertyAnimation, Qt, QTimer, Signal, Slot
|
||||
from PySide6.QtGui import QCursor, QFont, QFontMetrics
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QMenu, QPushButton, QSizePolicy, QToolTip
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QGraphicsOpacityEffect,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QToolTip,
|
||||
)
|
||||
|
||||
from aare.gui.styles import FONT_VALUE, THEME_SUNRISE, state_colors
|
||||
|
||||
@@ -133,6 +142,7 @@ class BeamlineStatePanel(QFrame):
|
||||
self._hovered_state: BeamlineStateEnum | None = None
|
||||
self._pending_target_state: BeamlineStateEnum | None = None
|
||||
self._busy = False
|
||||
self._breathing_button: HoverableButton | None = None
|
||||
|
||||
# After 3 s of hovering an unavailable state, explain which states
|
||||
# it can be reached from.
|
||||
@@ -322,8 +332,31 @@ class BeamlineStatePanel(QFrame):
|
||||
self._style_separator(separator)
|
||||
self._apply_highlight()
|
||||
|
||||
def _sync_breathing(self, button: HoverableButton | None) -> None:
|
||||
# The transition target used to sit solid blue while Moving, reading
|
||||
# as "already there". Breathe it via an opacity effect instead of a
|
||||
# stylesheet animation: restyling would repolish the button per frame.
|
||||
if button is self._breathing_button:
|
||||
return
|
||||
if self._breathing_button is not None:
|
||||
# Qt deletes the old effect (and the animation parented to it).
|
||||
self._breathing_button.setGraphicsEffect(None) # pyright: ignore[reportArgumentType]
|
||||
self._breathing_button = button
|
||||
if button is None:
|
||||
return
|
||||
effect = QGraphicsOpacityEffect(button)
|
||||
button.setGraphicsEffect(effect)
|
||||
animation = QPropertyAnimation(effect, QByteArray(b"opacity"), effect)
|
||||
animation.setDuration(1600)
|
||||
animation.setStartValue(1.0)
|
||||
animation.setKeyValueAt(0.5, 0.35)
|
||||
animation.setEndValue(1.0)
|
||||
animation.setLoopCount(-1)
|
||||
animation.start()
|
||||
|
||||
def _apply_highlight(self) -> None:
|
||||
available = self._available_targets()
|
||||
pending_button: HoverableButton | None = None
|
||||
for state, button in self._buttons.items():
|
||||
is_current = state == self._current_state
|
||||
is_pending = (
|
||||
@@ -331,6 +364,8 @@ class BeamlineStatePanel(QFrame):
|
||||
and self._current_state == BeamlineStateEnum.Moving
|
||||
)
|
||||
is_available = state in available
|
||||
if is_pending:
|
||||
pending_button = button
|
||||
|
||||
# Availability drives the look: active = bold (red for
|
||||
# Maintenance, blue otherwise), reachable = orange, rest = grey.
|
||||
@@ -391,6 +426,8 @@ class BeamlineStatePanel(QFrame):
|
||||
if button.toolTip() != tooltip:
|
||||
button.setToolTip(tooltip)
|
||||
|
||||
self._sync_breathing(pending_button)
|
||||
|
||||
def set_current_state(self, state: BeamlineStateEnum | None) -> None:
|
||||
self._current_state = state
|
||||
if (
|
||||
|
||||
@@ -85,6 +85,17 @@ class DataCollectionSettings(QFrame):
|
||||
centering_layout.addWidget(self.find_tip)
|
||||
centering_layout.addWidget(self.bounding_box)
|
||||
|
||||
# Live readout mirrored from the Beamline setup panel, same reason:
|
||||
# "what is" and "what to set" must not share one ambiguous row.
|
||||
# Fed per DAQ tick in update_daq_status.
|
||||
self.current_energy_label = QLabel("—", parent=self)
|
||||
current_energy_row = QWidget(self)
|
||||
current_energy_layout = QHBoxLayout(current_energy_row)
|
||||
current_energy_layout.setContentsMargins(0, 0, 0, 0)
|
||||
current_energy_layout.addWidget(QLabel("Current energy / λ", parent=current_energy_row))
|
||||
current_energy_layout.addWidget(self.current_energy_label)
|
||||
current_energy_layout.addStretch()
|
||||
|
||||
# Energy row copied from the Beamline setup panel so users can change
|
||||
# energy without leaving the experiment configuration.
|
||||
self.energy_spin = QDoubleSpinBox(parent=self)
|
||||
@@ -97,7 +108,7 @@ class DataCollectionSettings(QFrame):
|
||||
energy_row = QWidget(self)
|
||||
energy_layout = QHBoxLayout(energy_row)
|
||||
energy_layout.setContentsMargins(0, 0, 0, 0)
|
||||
energy_layout.addWidget(QLabel("Energy (keV)", parent=energy_row))
|
||||
energy_layout.addWidget(QLabel("Set Energy (keV)", parent=energy_row))
|
||||
energy_layout.addWidget(self.energy_spin)
|
||||
energy_layout.addWidget(self.change_energy_button)
|
||||
|
||||
@@ -110,6 +121,7 @@ class DataCollectionSettings(QFrame):
|
||||
# border, and the Abort button should hug the pane.
|
||||
pane_layout.setContentsMargins(6, 6, 6, 0)
|
||||
pane_layout.addWidget(centering_row)
|
||||
pane_layout.addWidget(current_energy_row)
|
||||
pane_layout.addWidget(energy_row)
|
||||
pane_layout.addWidget(self._stack)
|
||||
|
||||
@@ -182,6 +194,17 @@ class DataCollectionSettings(QFrame):
|
||||
|
||||
@Slot(DAQStatusModel)
|
||||
def update_daq_status(self, s: DAQStatusModel):
|
||||
energy = s.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 / {s.diffraction.wavelength_angstrom:.4f} Å"
|
||||
# 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)
|
||||
|
||||
self.raster.update_daq_status(s)
|
||||
self.screening.update_daq_status(s)
|
||||
self.simple.update_daq_status(s)
|
||||
|
||||
@@ -2,7 +2,7 @@ import numpy as np
|
||||
from aarecommon.config.logger import setup_logger
|
||||
from aarecommon.models.models import DAQStatusModel, FluorescenceSpectrumOutputModel
|
||||
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis
|
||||
from PySide6.QtCore import QEvent, QPointF, Qt, Slot
|
||||
from PySide6.QtCore import QEvent, Qt, Slot
|
||||
from PySide6.QtGui import QPainter, QPen
|
||||
from PySide6.QtWidgets import QGraphicsSimpleTextItem, QGridLayout, QLabel, QWidget
|
||||
|
||||
@@ -82,8 +82,7 @@ class FluorescencePanel(QWidget):
|
||||
def eventFilter(self, obj, event):
|
||||
try:
|
||||
if obj is self.chart_view.viewport() and event.type() == QEvent.Type.MouseMove:
|
||||
pos = event.position() if hasattr(event, "position") else event.pos()
|
||||
p = QPointF(pos.x(), pos.y())
|
||||
p = event.position()
|
||||
plot = self.chart.plotArea()
|
||||
if not plot.contains(p) or self.series.count() == 0:
|
||||
self.chart_view.setToolTip("")
|
||||
@@ -94,19 +93,12 @@ class FluorescencePanel(QWidget):
|
||||
(p.x() - plot.left()) / plot.width()
|
||||
)
|
||||
|
||||
# Snap to the largest Y within +/- 3 indices around nearest index
|
||||
# Snap to the largest Y within +/- 10 indices around nearest index
|
||||
center = self._nearest_index(x_val)
|
||||
n = self.series.count()
|
||||
left = max(0, center - 10)
|
||||
right = min(n - 1, center + 10)
|
||||
|
||||
best_i = left
|
||||
best_y = self.series.at(best_i).y()
|
||||
for i in range(left + 1, right + 1):
|
||||
yi = self.series.at(i).y()
|
||||
if yi > best_y:
|
||||
best_y = yi
|
||||
best_i = i
|
||||
best_i = max(range(left, right + 1), key=lambda i: self.series.at(i).y())
|
||||
|
||||
pt = self.series.at(best_i)
|
||||
self.chart_view.setToolTip(f"Energy {pt.x():.3f} keV counts {pt.y():.3f}")
|
||||
|
||||
@@ -2,6 +2,7 @@ from aarecommon.models.models import DAQStatusModel
|
||||
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.title_label import TitleLabel
|
||||
|
||||
|
||||
@@ -9,6 +10,8 @@ class MonochromatorPanel(QWidget):
|
||||
mono_pitch_scan = Signal()
|
||||
change_energy = Signal(float)
|
||||
move_beam_to_box = Signal()
|
||||
open_shutter = Signal()
|
||||
close_shutter = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -26,22 +29,44 @@ class MonochromatorPanel(QWidget):
|
||||
self.mono_pitch_scan_button.clicked.connect(self.mono_pitch_scan.emit)
|
||||
grid_layout.addWidget(self.mono_pitch_scan_button, 1, 0, 1, 3)
|
||||
|
||||
# Live readout above the setpoint, so "what is" and "what to set"
|
||||
# stop sharing one ambiguous Energy row. Fed per DAQ tick.
|
||||
grid_layout.addWidget(QLabel("Current energy / λ", parent=self), 2, 0)
|
||||
self.current_energy_label = QLabel("—", parent=self)
|
||||
grid_layout.addWidget(self.current_energy_label, 2, 1, 1, 2)
|
||||
|
||||
# One row (label | value | button) instead of three — vertical space.
|
||||
# Display in keV; the DAQ API stays in eV (converted on emit).
|
||||
# Unit lives in the label, not as a spinbox suffix — the suffix ate
|
||||
# field width and sat between the value and the +/- arrow.
|
||||
grid_layout.addWidget(QLabel("Energy (keV)", parent=self), 2, 0)
|
||||
grid_layout.addWidget(QLabel("Set Energy (keV)", parent=self), 3, 0)
|
||||
|
||||
self.energy_spin = QDoubleSpinBox(parent=self)
|
||||
self.energy_spin.setDecimals(3)
|
||||
self.energy_spin.setRange(1.0, 30.0)
|
||||
self.energy_spin.setSingleStep(0.1)
|
||||
self.energy_spin.setValue(12.0)
|
||||
grid_layout.addWidget(self.energy_spin, 2, 1)
|
||||
grid_layout.addWidget(self.energy_spin, 3, 1)
|
||||
|
||||
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, 2, 2)
|
||||
grid_layout.addWidget(self.change_energy_button, 3, 2)
|
||||
|
||||
# Fast shutter row: status left, Open/Close buttons right — same
|
||||
# rich-text scheme as the status bar flag so the two readouts match.
|
||||
# Colors are painted in code (set_theme), QSS can't reach the spans.
|
||||
self._colors = status_colors(THEME_SUNRISE)
|
||||
self._shutter_open: bool | None = None
|
||||
self.shutter_status_label = QLabel("Fast Shutter: —", parent=self)
|
||||
grid_layout.addWidget(self.shutter_status_label, 4, 0)
|
||||
|
||||
self.open_shutter_button = QPushButton("Open", parent=self)
|
||||
self.open_shutter_button.clicked.connect(self.open_shutter.emit)
|
||||
grid_layout.addWidget(self.open_shutter_button, 4, 1)
|
||||
|
||||
self.close_shutter_button = QPushButton("Close", parent=self)
|
||||
self.close_shutter_button.clicked.connect(self.close_shutter.emit)
|
||||
grid_layout.addWidget(self.close_shutter_button, 4, 2)
|
||||
|
||||
# TODO(wire backend): no DAQ endpoint exists yet for moving the beam
|
||||
# to the box center — shown disabled as WIP until the operation is
|
||||
@@ -51,12 +76,43 @@ class MonochromatorPanel(QWidget):
|
||||
self.move_beam_to_box_button.setToolTip("Coming soon — not functional yet.")
|
||||
self.move_beam_to_box_button.setEnabled(False)
|
||||
self.move_beam_to_box_button.clicked.connect(self.move_beam_to_box.emit)
|
||||
grid_layout.addWidget(self.move_beam_to_box_button, 3, 0, 1, 3)
|
||||
grid_layout.addWidget(self.move_beam_to_box_button, 5, 0, 1, 3)
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
"""Adopt the theme's flag colors and re-render the shutter status."""
|
||||
self._colors = status_colors(theme)
|
||||
self._render_shutter()
|
||||
|
||||
def _render_shutter(self) -> None:
|
||||
if self._shutter_open is None:
|
||||
text = "Fast Shutter: —"
|
||||
elif self._shutter_open:
|
||||
text = (
|
||||
f"""Fast Shutter: <span style="color: {self._colors["alert"]} ; "> Open ☢️ </span>"""
|
||||
)
|
||||
else:
|
||||
text = (
|
||||
f"""Fast Shutter: <span style="color: {self._colors["ok"]} ; "> Closed 🚪 </span>"""
|
||||
)
|
||||
if self.shutter_status_label.text() != text:
|
||||
self.shutter_status_label.setText(text)
|
||||
|
||||
@Slot()
|
||||
def _emit_change_energy(self):
|
||||
self.change_energy.emit(float(self.energy_spin.value()) * 1000.0)
|
||||
|
||||
@Slot(DAQStatusModel)
|
||||
def update_daq_status(self, _status: DAQStatusModel):
|
||||
pass
|
||||
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} Å"
|
||||
# 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)
|
||||
|
||||
self._shutter_open = bool(status.bl.shutter_open)
|
||||
self._render_shutter()
|
||||
|
||||
@@ -154,7 +154,6 @@ class LEDStages(QWidget):
|
||||
class PlayPauseButton(QPushButton):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._hovered = False
|
||||
self._running = False
|
||||
self.setFixedSize(64, 64)
|
||||
self.setMouseTracking(True)
|
||||
@@ -163,14 +162,6 @@ class PlayPauseButton(QPushButton):
|
||||
self._running = running
|
||||
self.update()
|
||||
|
||||
def enterEvent(self, event):
|
||||
self._hovered = True
|
||||
self.update()
|
||||
|
||||
def leaveEvent(self, event):
|
||||
self._hovered = False
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
@@ -178,7 +169,10 @@ class PlayPauseButton(QPushButton):
|
||||
cx, cy = rect.width() / 2, rect.height() / 2
|
||||
r = min(rect.width(), rect.height()) / 2 - 2
|
||||
|
||||
bg_color = qcolor(WHITE) if self._hovered else QColor(ACCENT)
|
||||
# underMouse() instead of enter/leave overrides tracking a _hovered
|
||||
# flag: QPushButton already repaints on hover (WA_Hover), so the
|
||||
# two extra Qt→Python callbacks bought nothing.
|
||||
bg_color = qcolor(WHITE) if self.underMouse() else QColor(ACCENT)
|
||||
p.setBrush(bg_color)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.drawEllipse(QPointF(cx, cy), r, r)
|
||||
|
||||
@@ -20,12 +20,16 @@ def get_entry(sample: SampleShortInfo, column: int):
|
||||
return sample.sample_name
|
||||
elif column == 2:
|
||||
return sample.mount_count
|
||||
# data() feeds this `header index - 1`: rotation/raster were cross-wired
|
||||
# against the header, so "Raster count" showed rotation counts and vice
|
||||
# versa. Order now matches the header (workflow order, same as the Dewar
|
||||
# table): mount -> raster -> screening -> rotation.
|
||||
elif column == 3:
|
||||
return sample.rotation_count
|
||||
elif column == 4:
|
||||
return sample.raster_count
|
||||
elif column == 5:
|
||||
elif column == 4:
|
||||
return sample.screening_count
|
||||
elif column == 5:
|
||||
return sample.rotation_count
|
||||
return ""
|
||||
|
||||
|
||||
@@ -48,8 +52,8 @@ class ReferenceToolsModel(QAbstractTableModel):
|
||||
"Sample name",
|
||||
"Mount count",
|
||||
"Raster count",
|
||||
"Rotation count",
|
||||
"Screening count",
|
||||
"Rotation count",
|
||||
]
|
||||
self._sort_col = 1
|
||||
self._sort_order = Qt.SortOrder.AscendingOrder
|
||||
|
||||
@@ -83,7 +83,7 @@ class SmargonPanel(QWidget):
|
||||
# Own row: sharing row 1 squeezed the Chi/Phi entry boxes.
|
||||
grid_layout.addWidget(self.move_group.button, 2, 0, 1, 7)
|
||||
|
||||
self.home_button = QPushButton("Move home", parent=self)
|
||||
self.home_button = QPushButton("Move to mounting position", parent=self)
|
||||
grid_layout.addWidget(self.home_button, 3, 0, 1, 7)
|
||||
self.home_button.clicked.connect(self.home)
|
||||
|
||||
|
||||
@@ -150,11 +150,15 @@ class TellSamplePanel(QFrame):
|
||||
# Left margin 0: "All" shares the table's left edge; bottom 0: the
|
||||
# row sits directly on the table.
|
||||
chip_row.setContentsMargins(0, 2, 6, 0)
|
||||
chip_row.setSpacing(0)
|
||||
# 1px gap so the chips read as separate buttons, not one solid bar.
|
||||
chip_row.setSpacing(1)
|
||||
self.status_chips = QButtonGroup(self)
|
||||
self.status_chips.setExclusive(True)
|
||||
for label, key in (
|
||||
("All", None),
|
||||
# Unmeasured next to All: both are "what is left" views, the
|
||||
# remaining chips are hand-applied labels.
|
||||
("Unmeasured", "unmeasured"),
|
||||
("Queued", "queued"),
|
||||
("Flagged", "flagged"),
|
||||
("Measured", "measured"),
|
||||
@@ -174,7 +178,12 @@ class TellSamplePanel(QFrame):
|
||||
else:
|
||||
chip = QPushButton(label, self)
|
||||
if key == "measured":
|
||||
chip.setToolTip("Filter measured samples (automatic: rotation count > 1)")
|
||||
chip.setToolTip("Filter measured samples (automatic: rotation count ≥ 1)")
|
||||
elif key == "unmeasured":
|
||||
chip.setToolTip(
|
||||
"Filter samples with no rotation data yet — select all here to"
|
||||
" queue everything still to be done"
|
||||
)
|
||||
chip.setCheckable(True)
|
||||
chip.setChecked(key is None)
|
||||
chip.setProperty("status_key", key)
|
||||
@@ -228,6 +237,16 @@ class TellSamplePanel(QFrame):
|
||||
# handler only uses the row, which both views share).
|
||||
self.table_view.frozen.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.table_view.frozen.customContextMenuRequested.connect(self.context_menu)
|
||||
# "#" holds no sortable data; clicking its header selects all rows
|
||||
# instead (Ctrl+A) — the quick way to act on everything visible.
|
||||
# The frozen overlay owns the visible "#" header (not sorting-enabled,
|
||||
# so its sections need explicit clickability); the main header is
|
||||
# connected too in case the overlay is ever dropped.
|
||||
self.table_view.frozen.horizontalHeader().setSectionsClickable(True)
|
||||
self.table_view.frozen.horizontalHeader().sectionClicked.connect(
|
||||
self._select_all_from_status_header
|
||||
)
|
||||
header.sectionClicked.connect(self._select_all_from_status_header)
|
||||
|
||||
header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
header.customContextMenuRequested.connect(self.header_context_menu)
|
||||
@@ -283,6 +302,17 @@ class TellSamplePanel(QFrame):
|
||||
chip.setChecked(True)
|
||||
return
|
||||
|
||||
@Slot(int)
|
||||
def _select_all_from_status_header(self, section: int) -> None:
|
||||
if section != COL_STATUS:
|
||||
return
|
||||
self.table_view.selectAll()
|
||||
# The click also dragged the sort indicator onto "#" (the model
|
||||
# ignores sorting there) — put it back where the data actually is.
|
||||
self.table_view.horizontalHeader().setSortIndicator(
|
||||
self.table_model._sort_col, self.table_model._sort_order
|
||||
)
|
||||
|
||||
def _selected_samples(self, clicked_row: int) -> list[SampleShortInfo]:
|
||||
"""Selected rows if the clicked row is part of the selection, else
|
||||
just the clicked row — so right-click on an unselected row acts on it."""
|
||||
@@ -312,6 +342,11 @@ class TellSamplePanel(QFrame):
|
||||
mount_action = menu.addAction("Mount")
|
||||
# Unmount moved here from the removed bottom-row button — same signal.
|
||||
unmount_action = menu.addAction("Unmount")
|
||||
menu.addSeparator()
|
||||
# Flagging happens by dropping rows on the Flagged chip; this is the
|
||||
# only way back, so it lives here even though Flag does not.
|
||||
unflag_action = menu.addAction(f"Unflag{count}")
|
||||
unflag_action.setEnabled(any(s.db_id in self.table_model.flagged_ids for s in selected))
|
||||
|
||||
action = menu.exec_(self.table_view.viewport().mapToGlobal(position))
|
||||
|
||||
@@ -325,6 +360,10 @@ class TellSamplePanel(QFrame):
|
||||
elif action == remove_queue_action:
|
||||
self.remove_from_queue.emit(SampleShortInfoList(s=selected))
|
||||
self.table_view.clearSelection()
|
||||
elif action == unflag_action:
|
||||
for s in selected:
|
||||
self.table_model.set_flagged(s.db_id, False)
|
||||
self.table_view.clearSelection()
|
||||
|
||||
def header_context_menu(self, pos):
|
||||
header = self.table_view.horizontalHeader()
|
||||
|
||||
@@ -180,6 +180,9 @@ class BatonRequestDialog(QDialog):
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(1000)
|
||||
self._timer.timeout.connect(self._tick)
|
||||
# finished fires on accept/reject/close alike — replaces the
|
||||
# closeEvent override that existed only to stop this timer.
|
||||
self.finished.connect(self._timer.stop)
|
||||
self._timer.start()
|
||||
|
||||
def _tick(self):
|
||||
@@ -221,22 +224,13 @@ class BatonRequestDialog(QDialog):
|
||||
self._on_accept()
|
||||
|
||||
def _on_accept(self):
|
||||
self._timer.stop()
|
||||
self.accepted_signal.emit()
|
||||
self.accept()
|
||||
|
||||
def _on_refuse(self):
|
||||
self._timer.stop()
|
||||
self.refused_signal.emit()
|
||||
self.reject()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Closing the dialog counts as ignoring = auto-accept on timeout."""
|
||||
# Don't emit anything here - let the timeout handle it
|
||||
# or the SSE stream will close the dialog when resolved
|
||||
self._timer.stop()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
class BatonPendingDialog(QDialog):
|
||||
"""
|
||||
@@ -331,6 +325,9 @@ class BatonPendingDialog(QDialog):
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(1000)
|
||||
self._timer.timeout.connect(self._tick)
|
||||
# finished fires on accept/reject/close alike — replaces the
|
||||
# closeEvent override that existed only to stop this timer.
|
||||
self.finished.connect(self._timer.stop)
|
||||
self._timer.start()
|
||||
|
||||
def _tick(self):
|
||||
@@ -364,10 +361,5 @@ class BatonPendingDialog(QDialog):
|
||||
# Keep cancel button so they can abort the wait if they change their mind
|
||||
|
||||
def _on_cancel(self):
|
||||
self._timer.stop()
|
||||
self.cancelled_signal.emit()
|
||||
self.reject()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._timer.stop()
|
||||
super().closeEvent(event)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aarecommon.models.models import SessionsStateEnum
|
||||
@@ -42,6 +44,36 @@ class BusyOverlayStyle:
|
||||
# AxisVideoPanel strips it because only the sample-camera badge is a
|
||||
# click target and the hint invites a click.
|
||||
subtext: str = ""
|
||||
# Wave-animate the title letters. Only the busy/robot warnings: a static
|
||||
# "In viewing mode" badge must not look like an in-progress operation.
|
||||
animate: bool = False
|
||||
|
||||
|
||||
def draw_wave_text(
|
||||
painter: QPainter,
|
||||
x: int,
|
||||
baseline: int,
|
||||
text: str,
|
||||
fm: QFontMetrics,
|
||||
fg: QColor,
|
||||
shadow: QColor | None = None,
|
||||
amplitude: int = 5,
|
||||
) -> None:
|
||||
"""Per-letter hop wave for the busy warnings, so the text reads as an
|
||||
in-progress signal instead of a frozen label. The phase comes from the
|
||||
wall clock and repaints ride the ~20 fps camera frames — no timer here;
|
||||
if the stream stalls the wave freezes, which is acceptable (the text
|
||||
stays legible and a stalled feed has its own error surface)."""
|
||||
now = time.monotonic()
|
||||
for index, char in enumerate(text):
|
||||
# Clipped sine: letters rest on the baseline and hop up in sequence.
|
||||
lift = -round(amplitude * max(0.0, math.sin(now * 5.5 - index * 0.55)))
|
||||
if shadow is not None:
|
||||
painter.setPen(QPen(shadow))
|
||||
painter.drawText(QPoint(x + 1, baseline + lift + 1), char)
|
||||
painter.setPen(QPen(fg))
|
||||
painter.drawText(QPoint(x, baseline + lift), char)
|
||||
x += fm.horizontalAdvance(char)
|
||||
|
||||
|
||||
def draw_busy_badge(
|
||||
@@ -89,7 +121,12 @@ def draw_busy_badge(
|
||||
painter.setFont(font)
|
||||
title_x = position_x + (bg_width - title_width) // 2
|
||||
title_y = position_y + padding_y + font_metrics.ascent()
|
||||
painter.drawText(QPoint(title_x, title_y), style.text)
|
||||
if style.animate:
|
||||
draw_wave_text(
|
||||
painter, title_x, title_y, style.text, font_metrics, QColor(style.overlay_text)
|
||||
)
|
||||
else:
|
||||
painter.drawText(QPoint(title_x, title_y), style.text)
|
||||
|
||||
if style.subtext:
|
||||
painter.setFont(sub_font)
|
||||
@@ -115,6 +152,17 @@ def draw_busy_status_text(
|
||||
|
||||
x = (viewport_width - font_metrics.horizontalAdvance(style.text)) // 2
|
||||
baseline = int(viewport_height * 0.68) + font_metrics.ascent() // 2
|
||||
if style.animate:
|
||||
draw_wave_text(
|
||||
painter,
|
||||
x,
|
||||
baseline,
|
||||
style.text,
|
||||
font_metrics,
|
||||
qcolor(style.badge_bg),
|
||||
shadow=qcolor(SHADOW, 200),
|
||||
)
|
||||
return
|
||||
painter.setPen(QPen(qcolor(SHADOW, 200)))
|
||||
painter.drawText(QPoint(x + 1, baseline + 1), style.text)
|
||||
painter.setPen(QPen(qcolor(style.badge_bg)))
|
||||
@@ -156,6 +204,7 @@ def build_busy_overlay_style(
|
||||
overlay_border=qcolor(BUSY_RED_BORDER, 230),
|
||||
overlay_text=qcolor(WHITE),
|
||||
accent_dot=BUSY_RED_DOT,
|
||||
animate=True,
|
||||
)
|
||||
|
||||
if activity_value == "unmounting":
|
||||
@@ -167,6 +216,7 @@ def build_busy_overlay_style(
|
||||
overlay_border=qcolor(BUSY_ORANGE_BORDER, 230),
|
||||
overlay_text=qcolor(WHITE),
|
||||
accent_dot=BUSY_ORANGE_DOT,
|
||||
animate=True,
|
||||
)
|
||||
|
||||
if activity_value == "drying":
|
||||
@@ -178,6 +228,7 @@ def build_busy_overlay_style(
|
||||
overlay_border=qcolor(BUSY_YELLOW_BORDER, 235),
|
||||
overlay_text=qcolor(BUSY_YELLOW_TEXT_DARK),
|
||||
accent_dot=BUSY_YELLOW_DOT,
|
||||
animate=True,
|
||||
)
|
||||
|
||||
if activity_value == "cooling":
|
||||
@@ -189,6 +240,7 @@ def build_busy_overlay_style(
|
||||
overlay_border=qcolor(BUSY_BLUE_BORDER, 235),
|
||||
overlay_text=qcolor(WHITE),
|
||||
accent_dot=BUSY_BLUE_DOT,
|
||||
animate=True,
|
||||
)
|
||||
|
||||
return BusyOverlayStyle(
|
||||
@@ -199,4 +251,5 @@ def build_busy_overlay_style(
|
||||
overlay_border=qcolor(BUSY_PSI_RED_BORDER, 235),
|
||||
overlay_text=qcolor(WHITE),
|
||||
accent_dot=BUSY_PSI_RED_DOT,
|
||||
animate=True,
|
||||
)
|
||||
|
||||
@@ -67,6 +67,7 @@ from aare.gui.widgets.busy_overlay import (
|
||||
BusyOverlayStyle,
|
||||
build_busy_overlay_style,
|
||||
draw_busy_badge,
|
||||
draw_wave_text,
|
||||
)
|
||||
|
||||
logger = setup_logger(LOGGER_NAME)
|
||||
@@ -132,7 +133,9 @@ class SampleCameraImageLabel(QGraphicsView):
|
||||
|
||||
self._geom = geom
|
||||
self._bookmarks: SmargonBookmarkList = SmargonBookmarkList()
|
||||
self._autoscale = False
|
||||
# Fit-to-view from the first frame; the right-click "Scale to fit"
|
||||
# toggle can still switch back to 1:1.
|
||||
self._autoscale = True
|
||||
self._show_coords = False
|
||||
self._helical_start = SmargonCoordinate()
|
||||
self._helical_end = SmargonCoordinate()
|
||||
@@ -337,14 +340,26 @@ class SampleCameraImageLabel(QGraphicsView):
|
||||
self._session_badge_rect = None
|
||||
painter.setFont(font)
|
||||
baseline = int(self.viewport().height() * 0.68) + font_metrics.ascent() // 2
|
||||
self._draw_status_text(
|
||||
painter,
|
||||
style.text,
|
||||
style.badge_bg,
|
||||
self.viewport().width() // 2,
|
||||
baseline,
|
||||
font_metrics,
|
||||
)
|
||||
if style.animate:
|
||||
x = self.viewport().width() // 2 - font_metrics.horizontalAdvance(style.text) // 2
|
||||
draw_wave_text(
|
||||
painter,
|
||||
x,
|
||||
baseline,
|
||||
style.text,
|
||||
font_metrics,
|
||||
qcolor(style.badge_bg),
|
||||
shadow=qcolor(SHADOW, 200),
|
||||
)
|
||||
else:
|
||||
self._draw_status_text(
|
||||
painter,
|
||||
style.text,
|
||||
style.badge_bg,
|
||||
self.viewport().width() // 2,
|
||||
baseline,
|
||||
font_metrics,
|
||||
)
|
||||
painter.restore()
|
||||
return
|
||||
|
||||
@@ -609,12 +624,6 @@ class SampleCameraImageLabel(QGraphicsView):
|
||||
if self._pending_load_pos is not None:
|
||||
self.load_image.emit(self._pending_load_pos)
|
||||
self._pending_load_pos = None
|
||||
return
|
||||
|
||||
if self._pending_load_pos is not None:
|
||||
self.load_image.emit(self._pending_load_pos)
|
||||
self._pending_load_pos = None
|
||||
self.raster_timer.start(self.raster_timer_interval)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if not self._camera_interaction_enabled():
|
||||
@@ -769,12 +778,18 @@ class SampleCameraImageLabel(QGraphicsView):
|
||||
|
||||
@Slot(QPixmap)
|
||||
def update_pixmap(self, pixmap: QPixmap):
|
||||
size_changed = self.pixmap_item is None or self.pixmap_item.pixmap().size() != pixmap.size()
|
||||
if self.pixmap_item is not None: # Ensure pixmap_item exists
|
||||
self.pixmap_item.setPixmap(pixmap) # Update the pixmap in the item
|
||||
else:
|
||||
# If no pixmap item exists (rare case), create one
|
||||
self.pixmap_item = QGraphicsPixmapItem(pixmap)
|
||||
self.scene.addItem(self.pixmap_item)
|
||||
if size_changed and self._autoscale:
|
||||
# Refit when the frame size differs from what was fitted (real
|
||||
# stream resolution vs the 2000x2000 startup placeholder, or a
|
||||
# camera source switch) — resizeEvent only refits on view resize.
|
||||
self._scaling()
|
||||
self.viewport().update() # Request an update to redraw the view
|
||||
|
||||
@Slot(DAQStatusModel)
|
||||
@@ -1082,7 +1097,9 @@ class SampleCameraImageLabel(QGraphicsView):
|
||||
# ponytail: painted circle, not a real QWidget button — the overlay it
|
||||
# toggles is painter-drawn too, and a widget would need layout juggling.
|
||||
diameter = 22
|
||||
rect = QRectF(18, self.viewport().height() - diameter - 18, diameter, diameter)
|
||||
# Center top, not a corner: corners drift oddly on scale-to-fit,
|
||||
# the top middle stays put and stays out of the sample's way.
|
||||
rect = QRectF((self.viewport().width() - diameter) / 2, 18, diameter, diameter)
|
||||
|
||||
painter.save()
|
||||
painter.resetTransform()
|
||||
@@ -1141,7 +1158,9 @@ class SampleCameraImageLabel(QGraphicsView):
|
||||
+ padding * 2
|
||||
)
|
||||
|
||||
bg_rect = QRectF(18, max(18, self.viewport().height() - height - 18), width, height)
|
||||
# Top center, where the collapsed "?" badge sits — expanding must not
|
||||
# send the mouse to the other end of the view to close it again.
|
||||
bg_rect = QRectF((self.viewport().width() - width) / 2, 18, width, height)
|
||||
self._help_hit_rect = bg_rect # click anywhere on the box to close
|
||||
painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1))
|
||||
painter.setBrush(qcolor(LEGEND_BG, 190))
|
||||
@@ -1159,15 +1178,15 @@ class SampleCameraImageLabel(QGraphicsView):
|
||||
painter.drawText(QPointF(bg_rect.left() + padding, y + fm.ascent()), entry)
|
||||
y += line_height
|
||||
|
||||
# Trailing link to the full F1 dialog; underlined so it reads as clickable.
|
||||
# Trailing link to the full F1 dialog; underlined so it reads as
|
||||
# clickable, right-aligned like a dialog's action button.
|
||||
link_font = QFont(font)
|
||||
link_font.setUnderline(True)
|
||||
painter.setFont(link_font)
|
||||
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
|
||||
painter.drawText(QPointF(bg_rect.left() + padding, y + fm.ascent()), more_text)
|
||||
self._help_more_rect = QRectF(
|
||||
bg_rect.left() + padding, y, fm.horizontalAdvance(more_text), line_height
|
||||
)
|
||||
more_x = bg_rect.right() - padding - fm.horizontalAdvance(more_text)
|
||||
painter.drawText(QPointF(more_x, y + fm.ascent()), more_text)
|
||||
self._help_more_rect = QRectF(more_x, y, fm.horizontalAdvance(more_text), line_height)
|
||||
|
||||
painter.restore()
|
||||
|
||||
|
||||
@@ -86,24 +86,22 @@ class MotorMoveGroup(QObject):
|
||||
self._set_state(name, "pending")
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if event.type() == QEvent.Type.KeyPress and event.key() in (
|
||||
Qt.Key.Key_Return,
|
||||
Qt.Key.Key_Enter,
|
||||
if (
|
||||
event.type() == QEvent.Type.KeyPress
|
||||
and event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter)
|
||||
and obj in self._boxes.values()
|
||||
):
|
||||
for box in self._boxes.values():
|
||||
if obj is box:
|
||||
try:
|
||||
value = float(box.text())
|
||||
except ValueError:
|
||||
break
|
||||
bottom = box.range_validator.bottom()
|
||||
if value < bottom:
|
||||
QToolTip.showText(
|
||||
box.mapToGlobal(QPoint(0, box.height())),
|
||||
f"Too small — minimum value: {box.to_string(bottom)}",
|
||||
box,
|
||||
)
|
||||
break
|
||||
try:
|
||||
value = float(obj.text())
|
||||
except ValueError:
|
||||
return super().eventFilter(obj, event)
|
||||
bottom = obj.range_validator.bottom()
|
||||
if value < bottom:
|
||||
QToolTip.showText(
|
||||
obj.mapToGlobal(QPoint(0, obj.height())),
|
||||
f"Too small — minimum value: {obj.to_string(bottom)}",
|
||||
obj,
|
||||
)
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
@Slot()
|
||||
|
||||
@@ -98,8 +98,9 @@ class StatusBar(QStatusBar):
|
||||
self.addPermanentWidget(self.ring_current)
|
||||
self.addPermanentWidget(self.wvl)
|
||||
self.addPermanentWidget(self.cryo_label)
|
||||
self.addPermanentWidget(self.shutter_label)
|
||||
# Beam-path order: the hutch shutter sits upstream of the fast shutter.
|
||||
self.addPermanentWidget(self.exp_shutter_label)
|
||||
self.addPermanentWidget(self.shutter_label)
|
||||
self.addPermanentWidget(self.pgroup_label)
|
||||
self.addPermanentWidget(self.state_label)
|
||||
self.addPermanentWidget(self.tell_state_label)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QLabel
|
||||
from aare.gui.widgets.clickable_label import ClickableLabel
|
||||
|
||||
|
||||
class ValueLabel(QLabel):
|
||||
clicked = Signal()
|
||||
class ValueLabel(ClickableLabel):
|
||||
# clicked signal + left-click mousePressEvent inherited from
|
||||
# ClickableLabel — this class only adds the "descr: value unit" text.
|
||||
|
||||
def __init__(self, text: str, unit: str = "", parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -17,9 +17,3 @@ class ValueLabel(QLabel):
|
||||
)
|
||||
else:
|
||||
self.setText(f"{self._descr}: <b>{s}</b> {self._unit}")
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.clicked.emit()
|
||||
else:
|
||||
super().mousePressEvent(event)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from PySide6.QtCore import QRectF, Qt, Slot
|
||||
from PySide6.QtGui import QImage, QPainter, QPixmap
|
||||
from PySide6.QtGui import QImage, QKeySequence, QPainter, QPixmap, QShortcut
|
||||
from PySide6.QtWidgets import QGraphicsPixmapItem, QGraphicsScene, QGraphicsView
|
||||
|
||||
from aare.gui.widgets.busy_overlay import BusyOverlayStyle, draw_busy_status_text
|
||||
@@ -34,6 +34,20 @@ class VideoGraphicsView(QGraphicsView):
|
||||
|
||||
self._busy_overlay_style: BusyOverlayStyle | None = None
|
||||
|
||||
# QShortcut instead of a keyPressEvent override: one fewer Qt→Python
|
||||
# callback on the render path, same focus behavior (WidgetShortcut =
|
||||
# active only while the view has focus).
|
||||
for key, slot in (
|
||||
(Qt.Key.Key_F, self.fit_to_view),
|
||||
(Qt.Key.Key_R, self.reset_zoom),
|
||||
(Qt.Key.Key_Plus, self.zoom_in),
|
||||
(Qt.Key.Key_Equal, self.zoom_in),
|
||||
(Qt.Key.Key_Minus, self.zoom_out),
|
||||
):
|
||||
shortcut = QShortcut(QKeySequence(key), self)
|
||||
shortcut.setContext(Qt.ShortcutContext.WidgetShortcut)
|
||||
shortcut.activated.connect(slot)
|
||||
|
||||
@Slot(QImage)
|
||||
def update_frame(self, qt_image: QImage):
|
||||
"""Update the video frame"""
|
||||
@@ -92,19 +106,6 @@ class VideoGraphicsView(QGraphicsView):
|
||||
# Normal scrolling
|
||||
super().wheelEvent(event)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
"""Handle keyboard shortcuts"""
|
||||
if event.key() == Qt.Key.Key_F:
|
||||
self.fit_to_view()
|
||||
elif event.key() == Qt.Key.Key_R:
|
||||
self.reset_zoom()
|
||||
elif event.key() == Qt.Key.Key_Plus or event.key() == Qt.Key.Key_Equal:
|
||||
self.zoom_in()
|
||||
elif event.key() == Qt.Key.Key_Minus:
|
||||
self.zoom_out()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def drawForeground(self, painter: QPainter, rect: QRectF):
|
||||
super().drawForeground(painter, rect)
|
||||
|
||||
|
||||
@@ -121,3 +121,19 @@ def test_pending_target_cleared_on_arrival(qtbot):
|
||||
assert panel._pending_target_state == BeamlineStateEnum.SampleExchange
|
||||
panel.set_current_state(BeamlineStateEnum.SampleExchange)
|
||||
assert panel._pending_target_state is None
|
||||
|
||||
|
||||
def test_pending_target_breathes_only_while_moving(qtbot):
|
||||
panel = _panel(qtbot)
|
||||
panel.set_current_state(BeamlineStateEnum.Maintenance)
|
||||
panel._emit_for_state(BeamlineStateEnum.SampleExchange)
|
||||
target = panel._buttons[BeamlineStateEnum.SampleExchange]
|
||||
assert target.graphicsEffect() is None # not Moving yet
|
||||
|
||||
panel.set_current_state(BeamlineStateEnum.Moving)
|
||||
assert panel._breathing_button is target
|
||||
assert target.graphicsEffect() is not None
|
||||
|
||||
panel.set_current_state(BeamlineStateEnum.SampleExchange)
|
||||
assert panel._breathing_button is None
|
||||
assert target.graphicsEffect() is None
|
||||
|
||||
@@ -209,3 +209,19 @@ def test_alt_wheel_axis_swap_still_changes_exposure(camera):
|
||||
n = len(sent)
|
||||
camera.wheelEvent(_wheel(camera)) # zero delta: ignored
|
||||
assert len(sent) == n
|
||||
|
||||
|
||||
def test_autoscale_fits_from_the_first_frame(camera):
|
||||
from PySide6.QtGui import QPixmap
|
||||
|
||||
# Fit-to-view is the default; a frame whose size differs from the fitted
|
||||
# one (here: the 2000x2000 startup placeholder) must refit immediately,
|
||||
# not wait for the next view resize.
|
||||
assert camera._autoscale
|
||||
camera.update_pixmap(QPixmap(4000, 4000))
|
||||
assert camera.transform().m11() < 1.0
|
||||
|
||||
# The right-click toggle still restores 1:1.
|
||||
camera._autoscale = False
|
||||
camera._scaling()
|
||||
assert camera.transform().isIdentity()
|
||||
|
||||
@@ -14,7 +14,7 @@ def mock_ui_state():
|
||||
yield mock
|
||||
|
||||
|
||||
def test_main_window_init(qtbot, mock_ui_state):
|
||||
def test_main_window_init(qtbot, mock_ui_state, daq_status_factory):
|
||||
with (
|
||||
patch("requests.get") as mock_get,
|
||||
patch("aare.gui.main_window.DAQWorker"),
|
||||
@@ -71,6 +71,25 @@ def test_main_window_init(qtbot, mock_ui_state):
|
||||
win.data_collection._emit_change_energy()
|
||||
assert sent and abs(sent[0] - 12400.0) < 1e-6
|
||||
|
||||
# Motion watch: robot motion switches to the combined beamline view;
|
||||
# alignment beginning switches straight back to the sample camera even
|
||||
# while the busy flag is still set — busy moves during Sample
|
||||
# alignment ARE the alignment, so it never re-triggers the switch.
|
||||
win.update_daq_status(
|
||||
daq_status_factory(state=BeamlineStateEnum.RobotSampleExchange, busy=True)
|
||||
)
|
||||
assert win._watching_motion
|
||||
assert win.video_tab.currentWidget() is win.beamline_combined_panel
|
||||
win.update_daq_status(
|
||||
daq_status_factory(state=BeamlineStateEnum.SampleAlignment, busy=True)
|
||||
)
|
||||
assert not win._watching_motion
|
||||
assert win.video_tab.currentWidget() is win.sample_camera
|
||||
win.update_daq_status(
|
||||
daq_status_factory(state=BeamlineStateEnum.SampleAlignment, busy=True)
|
||||
)
|
||||
assert not win._watching_motion
|
||||
|
||||
|
||||
def test_main_window_mount_view(qtbot, mock_ui_state):
|
||||
with (
|
||||
|
||||
@@ -143,7 +143,8 @@ def _row_of(model, db_id):
|
||||
def status_model(sample_list):
|
||||
from aarecommon.models.models import DewarAddress, SampleShortInfo
|
||||
|
||||
# A measured sample: rotation_count > 1 (exactly 1 must NOT count).
|
||||
# A measured sample: any rotation data counts (exactly 1 MUST count;
|
||||
# unmeasured is rotation_count 0, like the fixture's samples 1-3).
|
||||
sample_list.append(
|
||||
SampleShortInfo(
|
||||
db_id=4,
|
||||
@@ -153,7 +154,7 @@ def status_model(sample_list):
|
||||
run_number=4,
|
||||
user="U1",
|
||||
pin=4,
|
||||
rotation_count=2,
|
||||
rotation_count=1,
|
||||
location=DewarAddress(segment="B", pos=1),
|
||||
)
|
||||
)
|
||||
@@ -180,7 +181,7 @@ def test_status_color_priority(status_model):
|
||||
model.set_queued_ids(set())
|
||||
assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_FLAGGED_BG.lower()
|
||||
|
||||
# Measured is automatic: rotation_count 2 counts, the fixture's 1-3 don't.
|
||||
# Measured is automatic: rotation_count 1 counts, the 0s of 1-3 don't.
|
||||
assert _status(model, _row_of(model, 4)) == SAMPLE_STATUS_MEASURED_BG.lower()
|
||||
assert _status(model, _row_of(model, 2)) is None
|
||||
|
||||
@@ -200,10 +201,27 @@ def test_status_filter_selects_rows(status_model):
|
||||
assert {model.get_id(r).db_id for r in range(model.rowCount())} == {3}
|
||||
model.set_status_filter("measured")
|
||||
assert {model.get_id(r).db_id for r in range(model.rowCount())} == {4}
|
||||
# Unmeasured is the complement: everything still to be done.
|
||||
model.set_status_filter("unmeasured")
|
||||
assert {model.get_id(r).db_id for r in range(model.rowCount())} == {1, 2, 3}
|
||||
model.set_status_filter(None)
|
||||
assert model.rowCount() == 4
|
||||
|
||||
|
||||
def test_queued_view_shows_queue_order(status_model):
|
||||
model = status_model
|
||||
# Queue order deliberately different from location/db order.
|
||||
model.set_queued_ids([3, 1, 2])
|
||||
model.set_status_filter("queued")
|
||||
assert [model.get_id(r).db_id for r in range(model.rowCount())] == [3, 1, 2]
|
||||
# Header sorts must not reorder the queue view — row 1 runs next.
|
||||
model.sort(1, Qt.SortOrder.AscendingOrder)
|
||||
assert [model.get_id(r).db_id for r in range(model.rowCount())] == [3, 1, 2]
|
||||
# Other views keep the normal header sort.
|
||||
model.set_status_filter(None)
|
||||
assert [model.get_id(r).db_id for r in range(model.rowCount())] != [3, 1, 2]
|
||||
|
||||
|
||||
def test_status_tints_are_context_dependent(status_model):
|
||||
from aare.gui.styles import SAMPLE_STATUS_FLAGGED_BG, SAMPLE_STATUS_QUEUED_BG
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from aare.gui.panels.monochromator_panel import MonochromatorPanel
|
||||
|
||||
|
||||
def test_current_energy_readout(qtbot, daq_status_factory):
|
||||
panel = MonochromatorPanel()
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
status = 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()
|
||||
qtbot.addWidget(panel)
|
||||
|
||||
# Placeholder until the first DAQ tick.
|
||||
assert panel.shutter_status_label.text() == "Fast Shutter: —"
|
||||
|
||||
status = daq_status_factory()
|
||||
panel.update_daq_status(status)
|
||||
assert "Closed" in panel.shutter_status_label.text()
|
||||
|
||||
status.bl = status.bl.model_copy(update={"shutter_open": True})
|
||||
panel.update_daq_status(status)
|
||||
assert "Open" in panel.shutter_status_label.text()
|
||||
|
||||
# Buttons relay to the same DAQ signals the status-bar menu uses.
|
||||
with qtbot.waitSignal(panel.open_shutter, timeout=1000):
|
||||
panel.open_shutter_button.click()
|
||||
with qtbot.waitSignal(panel.close_shutter, timeout=1000):
|
||||
panel.close_shutter_button.click()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Covers the code paths touched by the reduce-overwriting-qt-method
|
||||
refactor: Qt-native replacements (signals, shortcuts, underMouse) for
|
||||
virtual-method overrides, so the diff-coverage gate sees them executed."""
|
||||
|
||||
from PySide6.QtCore import QEvent, QPointF, Qt
|
||||
from PySide6.QtGui import QMouseEvent
|
||||
|
||||
from aare.gui.panels.fluorescence_panel import FluorescencePanel
|
||||
from aare.gui.panels.portrait_mode import PlayPauseButton
|
||||
from aare.gui.widgets.baton_request_dialog import BatonPendingDialog, BatonRequestDialog
|
||||
from aare.gui.widgets.value_label import ValueLabel
|
||||
from aare.gui.widgets.video_image import VideoGraphicsView
|
||||
|
||||
|
||||
def test_baton_dialogs_stop_timer_via_finished(qtbot):
|
||||
"""finished.connect replaced the closeEvent overrides: the timer must
|
||||
stop on accept, reject AND plain close — the path closeEvent used to
|
||||
handle."""
|
||||
req = BatonRequestDialog("someone")
|
||||
qtbot.addWidget(req)
|
||||
assert req._timer.isActive()
|
||||
req._on_accept()
|
||||
assert not req._timer.isActive()
|
||||
|
||||
req2 = BatonRequestDialog("someone")
|
||||
qtbot.addWidget(req2)
|
||||
req2._on_refuse()
|
||||
assert not req2._timer.isActive()
|
||||
|
||||
# close() only delivers a close event to a SHOWN dialog — same held for
|
||||
# the old closeEvent override, so showing first keeps the test honest.
|
||||
pend = BatonPendingDialog("user")
|
||||
qtbot.addWidget(pend)
|
||||
pend.show()
|
||||
qtbot.waitExposed(pend)
|
||||
assert pend._timer.isActive()
|
||||
pend.close()
|
||||
assert not pend._timer.isActive()
|
||||
|
||||
|
||||
def test_fluorescence_hover_snaps_to_peak(qtbot):
|
||||
"""Drives a MouseMove through the viewport filter: event.position()
|
||||
(the PyQt5-era hasattr fallback is gone) and the max(range) peak snap."""
|
||||
panel = FluorescencePanel()
|
||||
qtbot.addWidget(panel)
|
||||
panel.resize(500, 400)
|
||||
panel.show()
|
||||
qtbot.waitExposed(panel)
|
||||
|
||||
panel.axis_x.setRange(0.0, 10.0)
|
||||
panel.axis_y.setRange(0.0, 100.0)
|
||||
for i in range(50):
|
||||
panel.series.append(i * 0.2, 90.0 if i == 25 else 10.0)
|
||||
|
||||
plot = panel.chart.plotArea()
|
||||
pos = QPointF(plot.center())
|
||||
ev = QMouseEvent(
|
||||
QEvent.Type.MouseMove,
|
||||
pos,
|
||||
panel.chart_view.viewport().mapToGlobal(pos.toPoint()),
|
||||
Qt.MouseButton.NoButton,
|
||||
Qt.MouseButton.NoButton,
|
||||
Qt.KeyboardModifier.NoModifier,
|
||||
)
|
||||
assert panel.eventFilter(panel.chart_view.viewport(), ev) is False
|
||||
assert "keV" in panel.chart_view.toolTip()
|
||||
|
||||
|
||||
def test_video_view_shortcuts_replace_keypress_override(qtbot):
|
||||
view = VideoGraphicsView()
|
||||
qtbot.addWidget(view)
|
||||
view.show()
|
||||
qtbot.waitExposed(view)
|
||||
# WidgetShortcut context needs real focus; offscreen grants it only
|
||||
# after the window is active.
|
||||
view.activateWindow()
|
||||
view.setFocus()
|
||||
qtbot.waitUntil(view.hasFocus, timeout=2000)
|
||||
|
||||
qtbot.keyClick(view, Qt.Key.Key_Plus)
|
||||
assert view.zoom_factor > 1.0
|
||||
qtbot.keyClick(view, Qt.Key.Key_R)
|
||||
assert view.zoom_factor == 1.0
|
||||
qtbot.keyClick(view, Qt.Key.Key_Minus)
|
||||
assert view.zoom_factor < 1.0
|
||||
qtbot.keyClick(view, Qt.Key.Key_F) # fit_to_view: just must not raise
|
||||
|
||||
|
||||
def test_play_pause_button_paints_without_hover_overrides(qtbot):
|
||||
btn = PlayPauseButton()
|
||||
qtbot.addWidget(btn)
|
||||
btn.set_running(True)
|
||||
# grab() forces a real paintEvent pass over the underMouse() branch
|
||||
assert not btn.grab().isNull()
|
||||
|
||||
|
||||
def test_value_label_inherits_click(qtbot):
|
||||
label = ValueLabel("Energy", "keV")
|
||||
qtbot.addWidget(label)
|
||||
label.set_value("12.4")
|
||||
assert "12.4" in label.text()
|
||||
with qtbot.waitSignal(label.clicked, timeout=1000):
|
||||
qtbot.mousePress(label, Qt.MouseButton.LeftButton)
|
||||
@@ -124,6 +124,16 @@ def test_queue_drop_chip_accepts_sample_payloads(panel, qtbot, samples):
|
||||
chip.dropEvent(drop(bad))
|
||||
|
||||
|
||||
def test_status_header_click_selects_all(panel):
|
||||
header = panel.table_view.frozen.horizontalHeader()
|
||||
header.sectionClicked.emit(0)
|
||||
assert len(panel.table_view.selectionModel().selectedRows()) == 3
|
||||
# Other sections keep their normal sort-click behavior.
|
||||
panel.table_view.clearSelection()
|
||||
header.sectionClicked.emit(1)
|
||||
assert len(panel.table_view.selectionModel().selectedRows()) == 0
|
||||
|
||||
|
||||
def test_selected_samples_follow_the_click(panel):
|
||||
view = panel.table_view
|
||||
view.selectRow(0)
|
||||
|
||||
Reference in New Issue
Block a user