Last GUI fixes before leaving - hopefully #232
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 GUI’s 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user