Last GUI fixes before leaving - hopefully #232

Merged
duan_j merged 9 commits from feat/gui-statusbar-pin-right into main 2026-09-17 21:22:59 +02:00
17 changed files with 486 additions and 126 deletions
+3 -1
View File
@@ -94,7 +94,9 @@ async def _lock_hw():
yield
else:
if not hardware_busy_lock.acquire(blocking=False):
raise BeamlineBusyException("Beamline hardware lock is held by another worker")
raise BeamlineBusyException(
"Beamline is busy with another operation. Please try again later."
)
logger.debug("Hardware lock acquired by process")
try:
yield
+46 -14
View File
@@ -150,6 +150,10 @@ class ClickableCursorFilter(QObject):
forbidden cursor) still win: they set it later."""
def eventFilter(self, obj, event):
# Same PySide 6.9 quirk as WheelValueGuard: an app-level filter can be
# handed a QWidgetItem instead of a QEvent during layout teardown.
if not isinstance(event, QEvent):
return False
if event.type() == QEvent.Type.Polish and isinstance(
obj, (QAbstractButton, QComboBox, QSlider)
):
@@ -234,6 +238,9 @@ class MainWindow(QMainWindow):
self._remote_close_reason: str | None = None
self._remote_close_banner_active: bool = False
self._latest_daq_status: DAQStatusModel | None = None
# Queue head awaiting removal: popped only once /status shows it is no
# longer on the gonio, so a failed unmount never loses a sample.
self._pending_unmount_pop: int | None = None
self._tutorial_event_bus = TutorialEventBus(self)
self._tutorial_text_resolver = DictionaryTextResolver(MANUAL_MOUNT_TUTORIAL)
@@ -1004,7 +1011,9 @@ class MainWindow(QMainWindow):
self.sample_camera.autofocus.connect(self.daq.autofocus)
self.sample_camera.evaluate_grid.connect(self.raster.run_grid_scan)
# Context-menu "Evaluate grid" presses the panel button so it gets the
# same file-exists guard, and stays inert while the button is disabled.
self.sample_camera.evaluate_grid.connect(self.data_collection.raster.start_button.click)
self.data_collection.raster.evaluate_grid.connect(self.raster.run_grid_scan)
self.data_collection.raster.evaluate_grid_auto.connect(self.raster.run_grid_scan_auto)
@@ -1094,6 +1103,7 @@ class MainWindow(QMainWindow):
self.job_list_panel.viewer_track_online.connect(self.viewer.load_online)
self.sample_logic.sample_changed.connect(self.data_collection.file_path_panel.update_sample)
self.sample_logic.sample_changed.connect(self._on_mounted_sample_changed)
self.daq.update.connect(self.beamline.omega_panel.update_daq_status)
self.daq.update.connect(self.beamline.smargon_panel.update_daq_status)
@@ -2163,19 +2173,39 @@ class MainWindow(QMainWindow):
return None
def _mount_next_from_queue(self) -> None:
"""Manual step-through: mount queue head; if head already on gonio, pop it and mount the following one.
Pops only after /status confirms mount, so a failed robot move never loses a sample."""
"""Manual step-through: mount queue head; if head already on gonio, mount the following one.
The mounted head stays in the queue until /status confirms it left the
gonio (see ``_on_mounted_sample_changed``): a failed unmount must not
drop the sample that is still physically mounted.
"""
queue = self.job_list_panel.table_model.samples
mounted = getattr(self._latest_daq_status, "sample", None)
if queue and mounted is not None and mounted.db_id == queue[0].db_id:
self.job_list_panel.table_model.remove_sample(mounted.db_id)
queue = self.job_list_panel.table_model.samples
if not queue:
self._on_manual_unmount_requested() # or no-op; your call
return
self._on_manual_mount_requested(queue[0])
head_mounted = bool(queue) and mounted is not None and mounted.db_id == queue[0].db_id
target = queue[1] if head_mounted else (queue[0] if queue else None)
if target is None:
sent = self._on_manual_unmount_requested()
else:
sent = self._on_manual_mount_requested(target)
if sent and head_mounted:
self._pending_unmount_pop = queue[0].db_id
def _on_manual_mount_requested(self, sample, reference: bool = False) -> None:
@Slot(object)
def _on_mounted_sample_changed(self, sample) -> None:
"""Pop the pending queue head once /status shows it is off the gonio.
Intentionally not cleared on ``operation_failed``: a busy/duplicate
POST error must not cancel the pop for an exchange still in flight.
"""
pending = self._pending_unmount_pop
if pending is None:
return
if sample is not None and sample.db_id == pending:
return
self._pending_unmount_pop = None
self.job_list_panel.remove_samples([pending])
def _on_manual_mount_requested(self, sample, reference: bool = False) -> bool:
"""Pre-check the hutch before sending a manual mount to the server.
Gives the user an immediate pop-up if the door is open / alarm active,
@@ -2188,10 +2218,11 @@ class MainWindow(QMainWindow):
QMessageBox.critical(self, "Mounting Failed", reason)
except Exception:
logger.exception("Failed to show mount-blocked popup")
return
return False
self.daq.mount(sample, reference)
return True
def _on_manual_unmount_requested(self) -> None:
def _on_manual_unmount_requested(self) -> bool:
"""Block a manual unmount if the hutch isn't ready (robot can't move)."""
reason = self._hutch_blocks_mount()
if reason is not None:
@@ -2200,8 +2231,9 @@ class MainWindow(QMainWindow):
QMessageBox.critical(self, "Unmounting Failed", reason)
except Exception:
logger.exception("Failed to show unmount-blocked popup")
return
return False
self.daq.unmount()
return True
def _precondition_ok(self) -> bool:
"""Run the shared ring-current / shutter / door 'continue?' check.
+49 -62
View File
@@ -7,7 +7,6 @@ from aarecommon.models.models import DAQStatusModel, SampleShortInfo
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import QGridLayout, QLabel, QLineEdit, QMessageBox, QSpinBox, QWidget
from aare.gui.styles import PATH_WARN_TEXT
from aare.gui.widgets.title_label import TitleLabel
## Logic for filenames:
@@ -18,6 +17,22 @@ from aare.gui.widgets.title_label import TitleLabel
## 5. If sample is registered in the database as manual, it is by default placed in <date>/manual/<sample name>
def find_file_path_panel(widget) -> "FilePathPanel | None":
"""The FilePathPanel that owns ``widget``'s tab, or None outside the GUI.
Walks up the widget tree: the scan panels are handed parent=DataCollectionSettings
but QStackedWidget.addWidget() reparents them to the stack, so a plain
parent() lookup finds no file_path_panel and the Run guard is skipped.
"""
w = widget.parent()
while w is not None:
panel = getattr(w, "file_path_panel", None)
if panel is not None:
return panel
w = w.parent()
return None
class FilePathPanel(QWidget):
path_updated = Signal(str)
@@ -116,11 +131,16 @@ class FilePathPanel(QWidget):
pass
return str(root / p)
# Master files a run can produce: plain scans write "<run>_master.h5",
# X-ray Centering writes "<run>_raster2d_master.h5" / "<run>_raster1d_master.h5"
# (see daq/operations/raster/service.py). Exact names only: a glob or a
# directory test marks runs red that nothing will ever write.
_MASTER_SUFFIXES = ("_master.h5", "_raster2d_master.h5", "_raster1d_master.h5")
def _exists_for_run(self, expanded_base_with_run: str) -> bool:
# expanded_base_with_run is the base without scan-kind transforms yet
effective = self._effective_dataset_base(expanded_base_with_run)
# Consider master file and directory as taken
return os.path.exists(f"{effective}_master.h5") or os.path.exists(effective)
return any(os.path.exists(effective + suffix) for suffix in self._MASTER_SUFFIXES)
def update_filename(self):
dir_name = self.directory_edit.text()
@@ -138,31 +158,17 @@ class FilePathPanel(QWidget):
base += "run" if file_prefix == "" else file_prefix
# Find next free run number using effective dataset path
rn = run_number
while True:
candidate_base = self._expand_macros(base, rn)
if not self._exists_for_run(candidate_base):
break
rn += 1
if rn > self.run_number_edit.maximum():
break
if rn != run_number:
self.run_number_edit.blockSignals(True)
self.run_number_edit.setValue(rn)
self.run_number_edit.blockSignals(False)
# Literal: the name is exactly what the fields say. No silent skip to
# the next free run here, or the click-time guard below never sees a
# clash; the label just turns red until the user (or the guard) acts.
# Store the GUIs base (without applying scan-kind transforms) for wiring into requests later
self._filename = self._expand_macros(base, rn)
self._filename = self._expand_macros(base, run_number)
# Preview label shows the effective path (what will be written)
# Preview label shows the effective path (what will be written), in the
# plain theme color: a clash is reported by the Run guard's popup, not
# by a red label.
effective = self._effective_dataset_base(self._filename)
exists = os.path.exists(f"{effective}_master.h5") or os.path.exists(effective)
self.file_name_label.setText(effective + "_master.h5")
# Empty stylesheet = reset to the THEME text color (a hardcoded
# "default" black would be invisible on the dark theme).
self.file_name_label.setStyleSheet(f"color: {PATH_WARN_TEXT};" if exists else "")
self.path_updated.emit(self._filename)
@Slot()
@@ -231,42 +237,23 @@ class FilePathPanel(QWidget):
def effective_path_for_base(self, base_with_run: str) -> str:
return self._effective_dataset_base(base_with_run)
def next_free_run_from(self, start_rn: int) -> tuple[int, str]:
# Compute next free run number and updated base
dir_name = self.directory_edit.text().replace("{prefix}", self.file_prefix_edit.text())
base = dir_name if dir_name.endswith("/") or dir_name == "" else dir_name + "/"
base += "run" if self.file_prefix_edit.text() == "" else self.file_prefix_edit.text()
def file_path_error_box(self, scan_kind: str) -> bool:
"""Click-time guard for the Run buttons: True when the run may start.
rn = start_rn
while rn <= self.run_number_edit.maximum():
candidate_base = self._expand_macros(base, rn)
if not self._exists_for_run(candidate_base):
return rn, candidate_base
rn += 1
return start_rn, self._expand_macros(base, start_rn)
def file_path_error_box(self, scan_kind: str):
self.set_scan_kind(scan_kind)
base = self.filename # same base we emit
effective = self.effective_path_for_base(base)
if os.path.exists(f"{effective}_master.h5") or os.path.exists(effective):
reply = QMessageBox.question(
self,
"File exists",
# f"This file already exists:\n{effective}_master.h5\nDo you wish to overwrite?",
f"This file already exists:\n{effective}_master.h5\n Updating run number",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.No:
# bump run and update
curr = self.run_number_edit.value()
new_rn, _ = self.next_free_run_from(curr + 1)
self.run_number_edit.setValue(new_rn)
self.update_filename()
return False
else:
return False
return False
return True
On a clash: bump the run number by one, tell the user, refuse the run.
The next click checks the new name again, so nothing is ever skipped
without the user seeing it.
"""
self._scan_kind = scan_kind
self.update_filename() # label/path for the clicked kind (screening vs rotation)
if not self._exists_for_run(self.filename):
return True
path = self.effective_path_for_base(self.filename) + "_master.h5"
self.increment_run_number()
QMessageBox.warning(
self,
"File exists",
f"File already exists:\n{path}\n\n"
f"Run number increased to {self.run_number_edit.value()}. Please run data collection again.",
)
return False
+4 -3
View File
@@ -42,6 +42,7 @@ from PySide6.QtWidgets import (
)
from aare.gui.constants import LOGGER_NAME
from aare.gui.panels.file_path_panel import find_file_path_panel
from aare.gui.widgets.message_box import precondition_check
from aare.gui.widgets.number_line_edit import NumberLineEdit
@@ -388,9 +389,9 @@ class ScanSettingsPanel(QWidget):
):
logger.warning("Beamline not ready; user chose not to continue scan")
return False
p = self.parent()
if hasattr(p, "file_path_panel"):
reply = p.file_path_panel.file_path_error_box(scan_kind=scan_kind)
file_path_panel = find_file_path_panel(self)
if file_path_panel is not None:
reply = file_path_panel.file_path_error_box(scan_kind=scan_kind)
logger.debug(f"reply from file path panel: {reply}")
if not reply:
logger.warning("Error with file path.")
@@ -9,6 +9,7 @@ from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QSizePolicy, QSpacerItem, QWidget
from aare.gui.constants import LOGGER_NAME
from aare.gui.panels.file_path_panel import find_file_path_panel
from aare.gui.panels.rotation_data_collection import (
MAX_OMEGA_SPEED_DEG_S,
MIN_EXP_TIME_S,
@@ -515,6 +516,13 @@ class SimpleRotationSettingsPanel(QWidget):
@Slot()
def run_measurement(self):
# Same file-exists guard as the Rotation tab; this panel is not a
# ScanSettingsPanel, so it has no check_before_run() to inherit.
file_path_panel = find_file_path_panel(self)
if file_path_panel is not None and not file_path_panel.file_path_error_box(
scan_kind="rotation"
):
return
# Send exactly what the panel last calculated and displayed, rather
# than re-reading the widgets: dtz, exposure and transmission are a
# single consistent solution and must not be mixed with a newer entry.
+39 -3
View File
@@ -5,7 +5,7 @@ from aarecommon.models.models import (
SampleShortInfo,
SampleShortInfoList,
)
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtCore import QEvent, Qt, QTimer, Signal, Slot
from PySide6.QtWidgets import (
QAbstractItemView,
QButtonGroup,
@@ -34,6 +34,10 @@ class FrozenColumnTableView(QTableView):
FROZEN_WIDTH = 36
# Emitted after this view's font changed (Ctrl+plus/minus zoom): the
# panel re-autosizes its columns, which were sized once at startup.
font_changed = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.frozen = QTableView(self)
@@ -71,6 +75,12 @@ class FrozenColumnTableView(QTableView):
self._update_frozen_geometry()
def _update_frozen_geometry(self) -> None:
# The overlay header must be exactly as tall as the main one, or its
# rows sit above the main rows. Qt sizes a header to its tallest
# visible section, and the main header also sees the symbol columns
# ("⧂", "⌕"), whose fallback-font glyphs have a taller line box than
# "#" at some zoom steps (1px off at 125% on macOS fonts).
self.frozen.horizontalHeader().setFixedHeight(self.horizontalHeader().height())
self.frozen.setGeometry(
self.frameWidth(),
self.frameWidth(),
@@ -82,6 +92,19 @@ class FrozenColumnTableView(QTableView):
super().resizeEvent(event)
self._update_frozen_geometry()
def changeEvent(self, event) -> None:
super().changeEvent(event)
if event.type() in (QEvent.Type.FontChange, QEvent.Type.StyleChange):
# Zoom/theme re-lay the main header; re-sync the overlay once the
# layout settled. Deferred with `self` as receiver context so a
# pending call dies with the view (no callback into a dead C++
# object). Not an updateGeometries override or a header-signal
# slot: both get invoked during teardown and a Python callback on
# a half-destroyed view leaves a lost exception behind.
QTimer.singleShot(0, self, self._update_frozen_geometry)
if event.type() == QEvent.Type.FontChange:
self.font_changed.emit()
class QueueDropChip(QPushButton):
"""Filter chip that doubles as a drop target: dragging table rows onto it
@@ -129,6 +152,11 @@ class TellSamplePanel(QFrame):
used by the pop-out window so both panels operate on the same data,
tints and filters with no syncing."""
super().__init__(parent)
# Before the table view exists: its font_changed slot reads this, and
# a FontChange can already arrive while the view is being parented
# into this panel (raising inside a C++-invoked slot leaves PySide
# with a lost exception and a later "returned NULL" SystemError).
self._columns_autosized = False
if samples is None:
samples = SampleShortInfoList(s=[])
@@ -215,6 +243,7 @@ class TellSamplePanel(QFrame):
self.table_view = FrozenColumnTableView()
self.table_view.setShowGrid(False)
self.table_view.setAlternatingRowColors(True)
self.table_view.font_changed.connect(self._on_table_font_changed)
grid_layout.addWidget(self.table_view, 2, 0, 1, 4)
self.table_model = model if model is not None else UserSampleSpreadsheet(samples=samples.s)
@@ -263,8 +292,7 @@ class TellSamplePanel(QFrame):
# Columns at full content width (horizontal scroll instead of
# squishing); done ONCE so later data refreshes don't fight manual
# column adjustments.
self._columns_autosized = False
# column adjustments (zoom re-runs it, see _on_table_font_changed).
if self.table_model.rowCount() > 0:
self._autosize_columns()
@@ -279,6 +307,14 @@ class TellSamplePanel(QFrame):
self.table_view.set_frozen_width(FrozenColumnTableView.FROZEN_WIDTH)
self._columns_autosized = True
@Slot()
def _on_table_font_changed(self) -> None:
# Zoom changed the glyph widths under columns sized once at startup
# (headers got truncated). Deferred: the view's own font is updated
# only after this signal. Receiver context: cancels if we die first.
if self._columns_autosized:
QTimer.singleShot(0, self, self._autosize_columns)
@Slot(SampleShortInfoList)
def new_sample_list(self, samples: SampleShortInfoList):
self.table_model.updateData(samples=samples.s)
+15 -9
View File
@@ -613,6 +613,12 @@ FONT_FINE = "11px" # fine print, queue titles
FONT_SCALE_LADDER = (1.0, 1.25, 1.5)
_font_scale = 1.0
# Control box height (buttons, combos, entry boxes): scaled with the ladder
# in _palette() like FONT_*. As raw px the 16px cap clipped descenders once
# the 14px body font became 18/21px at the 125/150% stops.
CONTROL_HEIGHT = "16px"
CONTROL_HEIGHT_LOOSE = "24px" # dark-theme button cap (room for icon buttons)
def font_scale() -> float:
return _font_scale
@@ -680,7 +686,7 @@ def _palette() -> dict[str, str]:
# import FONT_* into local f-string QSS keep 1.0 — port them to the
# app sheet if zoom must reach them.
for k, v in mapping.items():
if k.startswith("font_") and v.endswith("px"):
if k.startswith(("font_", "control_")) and v.endswith("px"):
mapping[k] = f"{round(int(v[:-2]) * _font_scale)}px"
return mapping
@@ -780,8 +786,8 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str:
QPushButton, QToolButton, QComboBox {
background-color: $button_bg;
border: 1px solid $button_border;
min-height: 16px;
max-height: 16px;
min-height: $control_height;
max-height: $control_height;
padding: 1px 8px;
}
@@ -799,8 +805,8 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str:
QLineEdit, QAbstractSpinBox {
background-color: $input_bg;
border: 1px solid $button_border;
min-height: 16px;
max-height: 16px;
min-height: $control_height;
max-height: $control_height;
padding: 1px 6px;
}
@@ -1320,8 +1326,8 @@ def _sunset_stylesheet() -> str:
QPushButton, QToolButton, QComboBox {
background-color: $dark_elevated;
border: 1px solid $dark_border_faint;
min-height: 16px;
max-height: 24px;
min-height: $control_height;
max-height: $control_height_loose;
padding-top: 1px;
padding-bottom: 1px;
}
@@ -1332,8 +1338,8 @@ def _sunset_stylesheet() -> str:
QLineEdit, QAbstractSpinBox {
background-color: $dark_input_bg;
border: 1px solid $dark_border_faint;
min-height: 16px;
max-height: 16px;
min-height: $control_height;
max-height: $control_height;
padding-top: 1px;
padding-bottom: 1px;
}
+9 -2
View File
@@ -1277,6 +1277,12 @@ class SampleCameraImageLabel(QGraphicsView):
label = f"{bar_um / 1000.0:g} mm" if bar_um >= 1000.0 else f"{bar_um:g} µm"
return bar_um, label
def _mounted_hud_ink(self) -> tuple[str, str]:
"""(text, shadow) for the mounted-sample HUD: black on a white halo in
the light themes, white on black in Sunset. The camera image behind
is arbitrary, so the pair follows the theme rather than the pixels."""
return (WHITE, SHADOW) if self._dark_theme else (SHADOW, WHITE)
def _draw_mounted_sample(self, painter: QPainter):
# Top-left HUD line (legend sits bottom-left, scale bar bottom-right):
# which sample is on the gonio, readable without leaving the camera.
@@ -1297,9 +1303,10 @@ class SampleCameraImageLabel(QGraphicsView):
margin = 18
text = f"Currently mounted: {self._mounted_sample_name}"
baseline = margin + fm.ascent()
painter.setPen(QPen(qcolor(SHADOW, 200)))
ink, halo = self._mounted_hud_ink()
painter.setPen(QPen(qcolor(halo, 200)))
painter.drawText(QPointF(margin + 1, baseline + 1), text)
painter.setPen(QPen(qcolor(WHITE)))
painter.setPen(QPen(qcolor(ink)))
painter.drawText(QPointF(margin, baseline), text)
painter.restore()
+39 -14
View File
@@ -93,16 +93,15 @@ class StatusBar(QStatusBar):
self.exp_shutter_label = ValueLabel("ExpHutch Shutter", "", self)
# Every readout lives in ONE wrapping flow host: on narrow windows
# QStatusBar used to clip/hide the labels outright, now they wrap to
# extra rows and stay readable. Fixed order: passives first, then the
# operables (shared ClickableLabel hover affordance) — Cryo | Fast
# Shutter | State | p-group | Session at the end. The old left/right
# split (addWidget vs addPermanentWidget) is gone with the wrap; no
# showMessage is ever used here, so nothing hides the host.
# TODO(cryo): passive for now, but placed with the operables because a
# cryo operation (fill/anneal menu) is planned; when it gets a click
# handler, swap it to a ClickableLabel so it inherits the affordance.
# Split layout: the passive readouts wrap in a flow host (narrow
# windows used to clip them outright), while the four operables —
# Fast Shutter | State | p-group | Session — are addPermanentWidget
# so QStatusBar itself pins them to the right corner at a fixed spot
# regardless of wrapping; no showMessage is ever used here, so
# nothing hides either side.
# TODO(cryo): a cryo operation (fill/anneal menu) is planned; when it
# gets a click handler, swap to ClickableLabel and move it over to
# the permanent (right-pinned) group with the other operables.
self.info_host = FlowHost(self)
for widget in (
self.message_label,
@@ -116,14 +115,22 @@ class StatusBar(QStatusBar):
self.tell_state_label,
self.busy_label,
self.cryo_label,
self.shutter_label,
self.state_label,
self.pgroup_label,
self.session_label,
):
self.info_host.add_widget(widget)
self.addWidget(self.info_host, 1)
for widget in (self.shutter_label, self.state_label, self.pgroup_label, self.session_label):
self.addPermanentWidget(widget)
def _ratchet_widths(self, *labels: QLabel) -> None:
# ponytail: grow-only min-width ratchet — per-tick number width
# changes used to re-flow the row and jitter every neighbour; pin
# each label to the widest text it has shown. Never shrinks until
# restart, which is fine for a status bar; per-label fixed widths
# from font metrics if a pathological long value ever sticks.
for label in labels:
label.setMinimumWidth(max(label.minimumWidth(), label.sizeHint().width()))
def set_theme(self, theme: str) -> None:
"""Adopt the theme's flag colors: recolor the connection message and
re-render the DAQ-driven labels from the last status right away."""
@@ -156,6 +163,7 @@ class StatusBar(QStatusBar):
@Slot(float)
def update_sharpness(self, val: float):
self.sharpness.set_value(f"{val:.3f}")
self._ratchet_widths(self.sharpness)
@Slot(float)
def update_samcam_fps(self, fps: float):
@@ -166,6 +174,7 @@ class StatusBar(QStatusBar):
self.samcam_fps.set_value("error")
return
self.samcam_fps.set_value(f"{fps:.1f}")
self._ratchet_widths(self.samcam_fps)
@Slot(DAQStatusModel)
def update_daq_status(self, status: DAQStatusModel):
@@ -284,6 +293,21 @@ class StatusBar(QStatusBar):
html_content_session = f"""Session: {session_flag}"""
self.session_label.setText(html_content_session)
self._ratchet_widths(
self.flux,
self.transmission,
self.ring_current,
self.wvl,
self.cryo_label,
self.shutter_label,
self.exp_shutter_label,
self.tell_state_label,
self.busy_label,
self.state_label,
self.pgroup_label,
self.session_label,
)
except Exception:
logger.exception("Error updating DAQ status in status bar")
@@ -378,6 +402,7 @@ class StatusBar(QStatusBar):
text = "Session: Vacant"
self.session_label.setText(text)
self._ratchet_widths(self.session_label)
def show_session_menu(self, global_pos: QPoint | None = None):
menu = QMenu(self)
+8 -3
View File
@@ -1,4 +1,4 @@
from PySide6.QtCore import QEvent, QObject, Qt
from PySide6.QtCore import QObject, Qt
from PySide6.QtGui import QWheelEvent
from PySide6.QtWidgets import (
QAbstractScrollArea,
@@ -26,7 +26,10 @@ class WheelValueGuard(QObject):
GUARDED = (QAbstractSpinBox, QSlider, QDial, QComboBox, QTabBar)
def eventFilter(self, obj, event):
if event.type() == QEvent.Type.Wheel and isinstance(obj, self.GUARDED):
# isinstance, not event.type(): PySide 6.9 sometimes hands an app-level
# filter a QWidgetItem instead of a QEvent while layouts are torn down,
# and .type() then raises (pytest-qt CALL ERROR on a random test).
if isinstance(event, QWheelEvent) and isinstance(obj, self.GUARDED):
if event.buttons() & Qt.MouseButton.RightButton:
return False # right button held: deliberate value adjustment
area = obj.parentWidget()
@@ -45,7 +48,9 @@ class WheelValueGuard(QObject):
)
QApplication.sendEvent(area.viewport(), relayed)
return True
return super().eventFilter(obj, event)
# Not super().eventFilter(): QObject's does nothing but type-checks its
# arguments, so the stray QWidgetItem would raise there instead.
return False
if __name__ == "__main__":
+10
View File
@@ -324,3 +324,13 @@ def test_mounted_sample_hud_follows_status(camera):
# Unmount (sample gone from the status) clears the line again.
camera.update_daq_status(status)
assert camera._mounted_sample_name is None
def test_mounted_hud_ink_follows_theme(camera):
"""Mounted-sample HUD: dark ink + white halo on light themes, inverted on Sunset."""
from aare.gui.styles import SHADOW, THEME_SUNRISE, THEME_SUNSET, WHITE
camera.set_theme(THEME_SUNRISE)
assert camera._mounted_hud_ink() == (SHADOW, WHITE)
camera.set_theme(THEME_SUNSET)
assert camera._mounted_hud_ink() == (WHITE, SHADOW)
@@ -485,3 +485,124 @@ def test_transmission_rows_are_per_mode(panel, qapp, diffraction):
raster_label = _grid_widget(raster._layout, 2, 0)
assert isinstance(raster_label, QLabel)
assert raster_label.text() == "Transmission"
# ---------------------------------------------------------------------------
# File path panel: the Run buttons must not overwrite an existing dataset
# ---------------------------------------------------------------------------
@pytest.fixture
def file_panel(qapp):
from aare.gui.panels.file_path_panel import FilePathPanel
p = FilePathPanel()
p.directory_edit.setText("d")
p.file_prefix_edit.setText("x")
p.run_number_edit.setValue(1)
return p
def _taken(monkeypatch, *suffixes):
from aare.gui.panels import file_path_panel
monkeypatch.setattr(
file_path_panel.os.path, "exists", lambda path: any(path.endswith(s) for s in suffixes)
)
def test_run_is_blocked_with_a_popup_when_the_file_exists(file_panel, monkeypatch):
from PySide6.QtWidgets import QMessageBox
_taken(monkeypatch, "data/d/x_001_master.h5")
boxes = []
monkeypatch.setattr(QMessageBox, "warning", lambda *a, **k: boxes.append(a))
# Editing never skips a taken run silently: the name stays literal.
file_panel.set_scan_kind("rotation")
assert file_panel.run_number_edit.value() == 1
assert file_panel.file_path_error_box(scan_kind="rotation") is False
assert len(boxes) == 1
assert "data/d/x_001_master.h5" in boxes[0][2]
# Run number went up by one, so the next click can go ahead.
assert file_panel.run_number_edit.value() == 2
assert file_panel.file_path_error_box(scan_kind="rotation") is True
assert len(boxes) == 1
def test_run_proceeds_when_the_file_is_free(file_panel, monkeypatch):
from PySide6.QtWidgets import QMessageBox
_taken(monkeypatch)
monkeypatch.setattr(QMessageBox, "warning", lambda *a, **k: pytest.fail("no popup expected"))
assert file_panel.file_path_error_box(scan_kind="rotation") is True
assert file_panel.run_number_edit.value() == 1
def test_a_derived_dataset_counts_as_taken(file_panel, monkeypatch):
# X-ray centering writes <run>_raster2d_master.h5, not <run>_master.h5.
from aare.gui.panels import file_path_panel
_taken(monkeypatch, "raster/d/x_001_raster2d_master.h5")
monkeypatch.setattr(file_path_panel.QMessageBox, "warning", lambda *a, **k: None)
assert file_panel.file_path_error_box(scan_kind="raster") is False
assert file_panel.run_number_edit.value() == 2
def test_a_directory_or_a_sibling_run_is_not_taken(file_panel, monkeypatch):
# Only the exact master files count; anything else would block runs that
# nothing will write.
_taken(monkeypatch, "raster/d/x_001", "raster/d/x_0011_master.h5", "raster/d/x_001_data.h5")
file_panel.set_scan_kind("raster")
assert file_panel.file_path_error_box(scan_kind="raster") is True
def test_every_tab_reaches_the_file_guard(settings_panel, monkeypatch):
# The tabs live in a QStackedWidget, which reparents them: a parent()
# lookup of file_path_panel found nothing and every Run skipped the guard.
from aare.gui.panels import scan_settings_panel
asked = []
monkeypatch.setattr(
settings_panel.file_path_panel,
"file_path_error_box",
lambda scan_kind: asked.append(scan_kind) or False,
)
monkeypatch.setattr(scan_settings_panel, "precondition_check", lambda *a, **k: True)
scans = []
for panel in (settings_panel.screening, settings_panel.raster):
panel._beamline_state = BeamlineStateEnum.SampleAlignment
settings_panel.screening.rotation_scan.connect(scans.append)
settings_panel.simple.rotation_scan.connect(scans.append)
settings_panel.raster.evaluate_grid.connect(lambda: scans.append("grid"))
settings_panel.raster.evaluate_grid_auto.connect(lambda: scans.append("auto"))
settings_panel.screening.run_screening()
settings_panel.screening.run_measurement()
settings_panel.simple.run_measurement()
settings_panel.raster._on_evaluate_clicked()
settings_panel.raster._on_evaluate_auto_clicked()
assert asked == ["screening", "rotation", "rotation", "raster", "raster"]
assert scans == []
def test_simple_tab_run_is_blocked_by_the_file_guard(qapp):
from PySide6.QtWidgets import QWidget
from aare.gui.panels.smart_rotation_panel import SimpleRotationSettingsPanel
holder = QWidget()
cast(Any, holder).file_path_panel = types.SimpleNamespace(
file_path_error_box=lambda scan_kind: False
)
panel = SimpleRotationSettingsPanel(parent=holder)
requests = []
panel.rotation_scan.connect(requests.append)
panel.run_measurement()
assert requests == []
+28 -3
View File
@@ -3,10 +3,10 @@ from unittest.mock import MagicMock, patch
import pytest
from PySide6.QtCore import QSettings, Qt
from PySide6.QtWidgets import QApplication, QDockWidget
from PySide6.QtWidgets import QApplication, QDockWidget, QWidget, QWidgetItem
from aare.gui import styles
from aare.gui.main_window import MainWindow
from aare.gui.main_window import ClickableCursorFilter, MainWindow
from aare.gui.styles import THEME_BLUEBIRD, THEME_SUNRISE, THEME_SUNSET
@@ -16,7 +16,7 @@ def mock_ui_state():
yield mock
def test_main_window_init(qtbot, mock_ui_state, daq_status_factory):
def test_main_window_init(qtbot, mock_ui_state, daq_status_factory, sample_info):
with (
patch("requests.get") as mock_get,
patch("aare.gui.main_window.DAQWorker"),
@@ -117,6 +117,24 @@ def test_main_window_init(qtbot, mock_ui_state, daq_status_factory):
win._on_manual_unmount_requested()
unmount_mock.assert_called_once()
# Mount next: the mounted head stays queued until /status confirms it
# left the gonio, so a failed unmount never drops it from the queue.
head = sample_info
nxt = sample_info.model_copy(update={"db_id": 2, "sample_name": "sample2"})
win.job_list_panel.table_model.updateData([head, nxt])
mount_mock = cast(MagicMock, win.daq.mount)
win.update_daq_status(daq_status_factory(sample=head))
win.sample_logic.update_daq_status(daq_status_factory(sample=head))
with patch.object(win, "_hutch_blocks_mount", return_value=None):
win._mount_next_from_queue()
mount_mock.assert_called_once_with(nxt, False)
queued = lambda: [s.db_id for s in win.job_list_panel.table_model.samples]
assert queued() == [1, 2], "head must stay queued until unmount confirmed"
win.sample_logic.update_daq_status(daq_status_factory(sample=head))
assert queued() == [1, 2], "head still mounted (unmount failed) -> keep it"
win.sample_logic.update_daq_status(daq_status_factory(sample=nxt))
assert queued() == [2], "exchange confirmed by /status -> head popped"
# Motion watch: only the robot station switches to the combined
# beamline view. Moving no longer does (users kept losing the sample
# camera on short gonio moves), and busy alone never does — Sample
@@ -665,3 +683,10 @@ def test_sample_camera_frame_paints_visible_views_and_acks(qtbot, mock_ui_state,
# No subscriber (GUI started without a sample feed): the slot must not blow up.
win.prediction_thread = None
win._on_sample_camera_frame(QImage(4, 6, QImage.Format.Format_RGB888))
def test_cursor_filter_ignores_a_non_event_argument(qapp):
# PySide 6.9 can hand an app-level filter a QWidgetItem instead of a
# QEvent during layout teardown; raising there fails a random test.
widget = QWidget()
assert ClickableCursorFilter().eventFilter(widget, QWidgetItem(widget)) is False
+28 -11
View File
@@ -64,22 +64,39 @@ def test_state_menu_gates_beam_location_for_non_staff(qtbot, daq_status_factory,
assert not _state_menu_entries(non_staff)["Beam location (admin mode only)"]
def test_readouts_wrap_instead_of_clipping(qtbot):
def test_readout_width_ratchets_so_numbers_do_not_jitter(qtbot, daq_status_factory):
bar = _bar(qtbot)
# A wide value pins the minimum width; a narrower one must NOT shrink it,
# otherwise every tick re-flows the row and neighbours jitter.
bar.update_samcam_fps(1234.5)
bar.update_sharpness(0.123)
wide = bar.samcam_fps.minimumWidth()
assert wide >= bar.samcam_fps.sizeHint().width()
bar.update_samcam_fps(5.0)
assert bar.samcam_fps.minimumWidth() == wide
# The DAQ tick ratchets the rest, session-display path included.
bar.update_daq_status(daq_status_factory())
bar._update_session_display()
assert bar.ring_current.minimumWidth() >= bar.ring_current.sizeHint().width()
assert bar.session_label.minimumWidth() >= bar.session_label.sizeHint().width()
def test_readouts_wrap_instead_of_clipping(qtbot, daq_status_factory):
bar = _bar(qtbot)
# Labels are empty until a DAQ tick paints them; feed one so the flow
# measures realistic widths (empty labels never trigger a wrap).
bar.update_daq_status(daq_status_factory())
host = bar.info_host
flow = host.layout()
assert isinstance(flow, FlowLayout) # narrows Optional for the checker
# Every readout is in the flow host, none clipped away by QStatusBar.
for label in (
bar.flux,
bar.busy_label,
bar.cryo_label,
bar.shutter_label,
bar.state_label,
bar.pgroup_label,
bar.session_label,
):
# Passive readouts wrap in the flow host; the four operables are
# addPermanentWidget so QStatusBar pins them to the right corner and
# they never move when the flow wraps.
for label in (bar.flux, bar.busy_label, bar.cryo_label):
assert label.parentWidget() is host
for label in (bar.shutter_label, bar.state_label, bar.pgroup_label, bar.session_label):
assert label.parentWidget() is bar
assert label.parentWidget() is not host
# Narrow width -> the flow reports a taller (multi-row) height than one
# row, and resizing the host pins its minimum height to the wrapped
# height so the bar grows instead of cutting labels off.
+20
View File
@@ -0,0 +1,20 @@
"""App stylesheet knobs that must track the font zoom ladder."""
from aare.gui import styles
def test_control_height_scales_with_font_ladder():
"""Buttons/inputs pinned at 16px clipped descenders at 125/150%; the pin
must scale with the ladder in both sheets."""
try:
styles.set_font_scale(1.0)
for theme in (styles.THEME_SUNRISE, styles.THEME_SUNSET):
assert "max-height: 16px" in styles.build_app_stylesheet(theme), theme
styles.set_font_scale(1.5)
for theme in (styles.THEME_SUNRISE, styles.THEME_SUNSET):
sheet = styles.build_app_stylesheet(theme)
assert "max-height: 24px" in sheet, theme
assert "max-height: 16px" not in sheet, theme
finally:
styles.set_font_scale(1.0)
+42
View File
@@ -142,3 +142,45 @@ def test_selected_samples_follow_the_click(panel):
assert [s.db_id for s in panel._selected_samples(0)] == [row_ids[0]]
# Click outside the selection: only the clicked row is acted on.
assert [s.db_id for s in panel._selected_samples(2)] == [row_ids[2]]
def test_font_zoom_keeps_frozen_column_aligned_and_reautosizes(panel, qtbot):
"""Ctrl+plus zoom: the frozen "#" overlay header must stay exactly as
tall as the main header (else its rows sit 1px higher), and the columns
sized once at startup must re-autosize for the wider glyphs."""
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QApplication
from aare.gui import styles
app = QApplication.instance()
assert isinstance(app, QApplication) # font() lives on QApplication, not QCoreApplication
base = QFont(app.font())
# Startup look first: under a styled ancestor (MainWindow) fonts reach
# the view through the QSS path; without any sheet Qt would not
# propagate the app font to a child that carries WA_StyleSheet.
panel.setStyleSheet(styles.build_app_stylesheet(styles.THEME_SUNRISE))
panel.resize(600, 300)
panel.show()
qtbot.waitExposed(panel)
view = panel.table_view
width_before = view.columnWidth(1)
def row0_top(v):
return v.viewport().mapTo(panel, v.viewport().rect().topLeft()).y() + v.rowViewportPosition(
0
)
try:
# Same three steps as MainWindow._apply_theme on a zoom change.
styles.set_font_scale(1.25)
big = QFont(base)
big.setPointSizeF(base.pointSizeF() * 1.25)
app.setFont(big)
panel.setStyleSheet(styles.build_app_stylesheet(styles.THEME_SUNRISE))
qtbot.waitUntil(lambda: view.columnWidth(1) > width_before)
assert view.frozen.horizontalHeader().height() == view.horizontalHeader().height()
assert row0_top(view.frozen) == row0_top(view)
finally:
styles.set_font_scale(1.0)
app.setFont(base)
+17 -1
View File
@@ -5,7 +5,15 @@ deliberate right-button + wheel gesture."""
import pytest
from PySide6.QtCore import QPoint, QPointF, Qt
from PySide6.QtGui import QWheelEvent
from PySide6.QtWidgets import QApplication, QScrollArea, QSlider, QSpinBox, QVBoxLayout, QWidget
from PySide6.QtWidgets import (
QApplication,
QScrollArea,
QSlider,
QSpinBox,
QVBoxLayout,
QWidget,
QWidgetItem,
)
from aare.gui.widgets.wheel_value_guard import WheelValueGuard
@@ -72,3 +80,11 @@ def test_bare_wheel_scrolls_the_enclosing_area(guard, qtbot):
QApplication.sendEvent(spin, _wheel(Qt.MouseButton.NoButton))
assert spin.value() == 50
assert bar.value() != before
def test_a_non_event_argument_is_ignored(guard, qtbot):
# PySide 6.9 can hand an app-level filter a QWidgetItem instead of a
# QEvent during layout teardown; raising there fails a random test.
widget = QWidget()
qtbot.addWidget(widget)
assert guard.eventFilter(widget, QWidgetItem(widget)) is False