From bd74ac47bc14e35c98146307d008d83cacf44644 Mon Sep 17 00:00:00 2001 From: Dawn Date: Sun, 13 Sep 2026 22:21:34 +0200 Subject: [PATCH 1/4] feat: text zoom shortcuts and high-contrast resize lines - Ctrl+plus / Ctrl+minus / Ctrl+0 (and View menu entries) scale the QSS FONT_* ladder and the application default font together; the scale is clamped to 0.8-1.6 and persisted in QSettings appearance/font_scale. - Resize gutters keep a 5px hover/drag region but paint only a 2px line with a 1px shadow (hard-stop gradients per orientation). - WCAG contrast fixes: separators were 1.0-1.2:1 against every theme background, now >=3.25:1 (dark theme gold @70%); scrollbar handle 1.17:1 -> 3.38:1; unselected tab text on the sky gradient 2.07:1 -> 4.71:1 via TAB_IDLE_TEXT. Co-Authored-By: Claude Fable 5 --- src/aare/gui/main_window.py | 44 +++++++ src/aare/gui/styles.py | 194 ++++++++++++++++++++++++----- tests/unit/gui/test_main_window.py | 47 ++++++- 3 files changed, 252 insertions(+), 33 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 40b98ec4..23e87354 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -31,6 +31,7 @@ from PySide6.QtGui import ( QActionGroup, QColor, QCursor, + QFont, QGuiApplication, QImage, QKeySequence, @@ -100,6 +101,7 @@ from aare.gui.styles import ( APP_BACKGROUND, DARK_TEXT, DOCK_CONTENT_LEFT_PAD, + FONT_SCALE_STEP, SEPARATOR_HINT_DELAY_MS, THEME_BLUEBIRD, THEME_FADE_MS, @@ -107,7 +109,9 @@ from aare.gui.styles import ( THEME_SUNSET, admin_tip_qss, build_app_stylesheet, + font_scale, qcolor, + set_font_scale, ) # Threads @@ -2002,6 +2006,15 @@ class MainWindow(QMainWindow): app.setPalette(palette) else: app.setPalette(self._default_palette) + # Text zoom, part 2: the QSS FONT_* ladder only reaches widgets the app + # sheet names — everything else renders in the app default font, so + # scale that too or body text ignores Ctrl+plus/minus. + if not hasattr(self, "_default_app_font"): + self._default_app_font = app.font() + font = QFont(self._default_app_font) + if font_scale() != 1.0: + font.setPointSizeF(self._default_app_font.pointSizeF() * font_scale()) + app.setFont(font) self.setStyleSheet(build_app_stylesheet(self._theme_mode)) # State colors are painted in code per DAQ tick — QSS can't reach them. self.beamline_state_panel.set_theme(self._theme_mode) @@ -2038,10 +2051,23 @@ class MainWindow(QMainWindow): # change (styles.py: "original"->"sunrise", "portrait"->"sunset"). saved = {"original": THEME_SUNRISE, "portrait": THEME_SUNSET}.get(saved, saved) self._theme_mode = saved + set_font_scale(float(settings.value("appearance/font_scale", 1.0, type=float))) def _save_theme_settings(self) -> None: settings = QSettings("PSI", "AareGUI") settings.setValue("appearance/theme", self._theme_mode) + settings.setValue("appearance/font_scale", font_scale()) + + def _change_font_zoom(self, direction: int) -> None: + """Ctrl+plus / Ctrl+minus / Ctrl+0 accessibility zoom: step the FONT_* + ladder scale and rebuild the app stylesheet (same repolish path as a + theme switch, so the whole UI rescales in one pass).""" + # round: repeated 0.1 float steps otherwise drift (1.2000000000000002) + set_font_scale( + 1.0 if direction == 0 else round(font_scale() + direction * FONT_SCALE_STEP, 2) + ) + self._save_theme_settings() + self._apply_theme() @Slot() def use_legacy_theme(self) -> None: @@ -2113,6 +2139,24 @@ class MainWindow(QMainWindow): view_menu.addAction(self._use_bluebird_theme_action) view_menu.addAction(self._use_portrait_theme_action) view_menu.addSeparator() + + # Accessibility text zoom. Ctrl+= alias: StandardKey.ZoomIn is + # Ctrl+Shift+= on some platforms and users expect plain Ctrl+plus. + bigger_text_action = QAction("Bigger Text", self) + bigger_text_action.setShortcuts( + [*QKeySequence.keyBindings(QKeySequence.StandardKey.ZoomIn), QKeySequence("Ctrl+=")] + ) + bigger_text_action.triggered.connect(lambda: self._change_font_zoom(1)) + view_menu.addAction(bigger_text_action) + smaller_text_action = QAction("Smaller Text", self) + smaller_text_action.setShortcuts(QKeySequence.StandardKey.ZoomOut) + smaller_text_action.triggered.connect(lambda: self._change_font_zoom(-1)) + view_menu.addAction(smaller_text_action) + reset_text_action = QAction("Reset Text Size", self) + reset_text_action.setShortcut(QKeySequence("Ctrl+0")) + reset_text_action.triggered.connect(lambda: self._change_font_zoom(0)) + view_menu.addAction(reset_text_action) + view_menu.addSeparator() view_menu.addAction(self._portrait_mode_action) view_menu.addAction(self._enter_automation_view_action) view_menu.addSeparator() diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index ec619d1d..719a0d2e 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -113,16 +113,44 @@ DOCK_CONTENT_LEFT_PAD = 10 # banner's bottom edge line. BANNER_TAB_GAP = 6 -# Resize-line hint: dock separators stay invisible until the mouse rests on -# one for SEPARATOR_HINT_DELAY_MS (or a drag starts) — then only the exact -# separator under the cursor fills with SEPARATOR_HINT. The rest/drag gate -# lives in MainWindow.event(); the QSS :hover part picks the one separator. -SEPARATOR_HINT = "rgba(168, 178, 192, 30%)" # scrollbar-track grey @50% -# Idle fill so the resize line is findable before the hover rest — 10% of the -# hover alpha. Any base QSS fill replaces the native dotted grip; accepted. -SEPARATOR_IDLE = "rgba(168, 178, 192, 10%)" +# Resize lines: the hover rest (SEPARATOR_HINT_DELAY_MS, gate in +# MainWindow.event(); the QSS :hover part picks the one separator) darkens the +# exact separator under the cursor to SEPARATOR_HINT. +# Accessibility (WCAG 1.4.11, >=3:1 non-text contrast): the old +# rgba(168,178,192) fills measured 1.0-1.2:1 against every theme background — +# invisible. Solid slate: idle #47536a >=3.25:1, hover #3f4a5f >=3.74:1 +# against gradient top/mid/bottom and panel white. +SEPARATOR_HINT = "#3f4a5f" +SEPARATOR_IDLE = "#47536a" SEPARATOR_HINT_DELAY_MS = 66 # int, used in code, not QSS +# The gutter keeps this full width for the mouse; only a 2px line + 1px +# shadow is painted inside it (a solid 5px bar read too heavy). 5px is a +# first guess — adjust here if the grab target feels off. +SEPARATOR_REGION = "5px" +SEPARATOR_SHADOW = "rgba(31, 41, 59, 25%)" + + +def _separator_gradient(line: str, shadow: str, axis: str) -> str: + """Paint of a resize gutter: transparent 1px, shadow 1px, line 2px, + shadow 1px — hard gradient stops at 1/5 steps of SEPARATOR_REGION. + axis "x" runs the gradient left->right (an upright line), "y" top->down + (a lying line).""" + x2, y2 = ("1", "0") if axis == "x" else ("0", "1") + return ( + f"qlineargradient(x1:0, y1:0, x2:{x2}, y2:{y2}," + f" stop:0 transparent, stop:0.19 transparent," + f" stop:0.2 {shadow}, stop:0.39 {shadow}," + f" stop:0.4 {line}, stop:0.79 {line}," + f" stop:0.8 {shadow}, stop:1 {shadow})" + ) + + +SEP_IDLE_X = _separator_gradient(SEPARATOR_IDLE, SEPARATOR_SHADOW, "x") +SEP_IDLE_Y = _separator_gradient(SEPARATOR_IDLE, SEPARATOR_SHADOW, "y") +SEP_HINT_X = _separator_gradient(SEPARATOR_HINT, SEPARATOR_SHADOW, "x") +SEP_HINT_Y = _separator_gradient(SEPARATOR_HINT, SEPARATOR_SHADOW, "y") + # Theme-switch screenshot cross-fade duration (int ms, used in code). THEME_FADE_MS = 250 @@ -226,7 +254,14 @@ DARK_DISABLED = "#3c4a66" # disabled — disabled fills # Accents — gold is IDENTITY (titles, highlights), blue is ACTION (primary # buttons); the site keeps the two apart on purpose: DARK_ACCENT = "#e0913f" # gold -DARK_SEPARATOR_IDLE = "rgba(224, 145, 63, 10%)" # DARK_ACCENT @10%, idle resize line +# DARK_ACCENT @70%: 10% measured 1.16:1 on DARK_BG (invisible); 70% blends to +# >=3.74:1 — same WCAG 1.4.11 floor as the light-theme separators. +DARK_SEPARATOR_IDLE = "rgba(224, 145, 63, 70%)" +DARK_SEPARATOR_SHADOW = "rgba(0, 0, 0, 45%)" +DARK_SEP_IDLE_X = _separator_gradient(DARK_SEPARATOR_IDLE, DARK_SEPARATOR_SHADOW, "x") +DARK_SEP_IDLE_Y = _separator_gradient(DARK_SEPARATOR_IDLE, DARK_SEPARATOR_SHADOW, "y") +DARK_SEP_HINT_X = _separator_gradient(DARK_ACCENT, DARK_SEPARATOR_SHADOW, "x") +DARK_SEP_HINT_Y = _separator_gradient(DARK_ACCENT, DARK_SEPARATOR_SHADOW, "y") DARK_ACCENT_HOVER = "#eaa253" # accent2 — brighter gold DARK_ACCENT_FILL = "#89b4fa" # action blue DARK_ACCENT_FILL_HOVER = "#9ec2fb" # +10% white, derived (site has no step) @@ -271,6 +306,10 @@ HINT_TEXT = "#9ca0b0" # baton dialog fine print (latte overlay0) HEADING_TEXT = "#4c4f69" # card headings (latte text) SUBTLE_TEXT = "#5c5f77" # card body text (latte subtext1) MUTED_TEXT = "#6c6f85" # neutral chip / idle step text (latte subtext0) +# Unselected tab labels: tabs also sit on the sky gradient, where MUTED_TEXT +# is 2.07:1 — under the 4.5:1 WCAG text floor. This slate reads 4.71:1 on the +# gradient top and stays muted next to TEXT. +TAB_IDLE_TEXT = "#2f3b52" FAINT_TEXT = "#8c8fa1" # pending/skipped step text (latte overlay1) SHADOW = "#000000" # drop shadows & tutorial scrim; alpha stays at call site @@ -471,6 +510,9 @@ LEGEND_BG = "#eff1f5" # base LEGEND_TEXT = "#4c4f69" # text TOOLTIP_TEXT = "#4c4f69" # camera coords tooltip pen — NOT the QToolTip popup SCALE_BAR_GREY = "#8c8fa1" # hover HUD scale bar (Latte overlay1 grey) +MARK_TOOLTIP_GOLD = "#df8e1d" # yellow +MARK_TOOLTIP_ORANGE = "#fe640b" # peach +MARK_TOOLTIP_RED = "#d20f39" # red MARK_BADGE_BG = "#fe640b" # peach # Prediction class overlay colors. The old pure-green vs CSS-green split @@ -500,11 +542,22 @@ BOOKMARK_COLORS = { "lime": "#179299", # teal — Latte has one green; teal keeps the pair distinct } -# -- Busy overlay: PSI red for every busy state, blue for robot cooling ------ +# -- Busy overlay (per-source color coding) --------------------------------- # Catppuccin Latte accents; BORDER/DOT are 25%/20% mixes toward Latte base. BUSY_YELLOW = "#df8e1d" # yellow BUSY_YELLOW_BORDER = "#ebd8bf" BUSY_YELLOW_DOT = "#ecddca" +BUSY_YELLOW_TEXT_DARK = "#4c4f69" # text +BUSY_PURPLE = "#8839ef" # mauve +BUSY_PURPLE_BORDER = "#d5c3f4" +BUSY_PURPLE_DOT = "#daccf4" +BUSY_RED_BADGE = "#d20f39" # red +BUSY_RED_FILL = "#d20f39" # red +BUSY_RED_BORDER = "#e8b8c6" +BUSY_RED_DOT = "#e9c4cf" +BUSY_ORANGE = "#fe640b" # peach +BUSY_ORANGE_BORDER = "#f3ceba" +BUSY_ORANGE_DOT = "#f2d5c6" BUSY_BLUE = "#1e66f5" # blue BUSY_BLUE_BORDER = "#bbcef5" BUSY_BLUE_DOT = "#c5d5f5" @@ -550,6 +603,27 @@ FONT_LABEL = "14px" # form labels, secondary text — always bold FONT_HINT = "12px" # hints FONT_FINE = "11px" # fine print, queue titles +# -- Font zoom (Ctrl+plus / Ctrl+minus, accessibility) ---------------------- +# Whole-app text scale applied to the FONT_* ladder when the QSS is built, so +# one re-apply of the stylesheet rescales every rule. Floats, so _palette()'s +# str filter never picks them up as colors. +FONT_SCALE_MIN = 0.8 +FONT_SCALE_MAX = 1.6 +FONT_SCALE_STEP = 0.1 +_font_scale = 1.0 + + +def font_scale() -> float: + return _font_scale + + +def set_font_scale(scale: float) -> None: + # Clamped: below 0.8 the fine-print sizes fall under 9px (unreadable), + # above 1.6 the fixed-height banner rows start clipping their text. + global _font_scale + _font_scale = max(FONT_SCALE_MIN, min(FONT_SCALE_MAX, scale)) + + # -- Hover tooltips (the QToolTip popup; styled borderless) ----------------- TOOLTIP_BG = "#f7f9fc" TOOLTIP_FG = "#263043" @@ -577,11 +651,12 @@ def admin_tip_qss(theme: str) -> str: SLIDER_FILL = "#8ba3c7" # -- Scrollbars (rounded, no arrows) ---------------------------------------- -# Flipped on request: the track is now the darker grey and the draggable -# handle the light one; hover therefore lightens further instead of darkening. +# Accessibility re-flip (WCAG 1.4.11): the light-on-light pairing measured +# 1.17:1 handle-vs-track — the handle was not findable. Dark handle on the +# light track gives 3.38:1 (5.02:1 on hover); hover darkens again. SCROLLBAR_TRACK = "#E4E4E4" -SCROLLBAR_HANDLE = "#D4D4D4" -SCROLLBAR_HANDLE_HOVER = "#C4C4C4" +SCROLLBAR_HANDLE = "#7a7a7a" +SCROLLBAR_HANDLE_HOVER = "#5f5f5f" # Disabled-input fill and the slider colors used to borrow the scrollbar # knobs; own knobs so the scrollbar flip above doesn't drag them along. @@ -598,7 +673,15 @@ FLAT_CARD_RADIUS = "0px" # recovery + console-log cards stay square def _palette() -> dict[str, str]: # Why: one substitution mapping instead of ~40 kwargs per stylesheet, and # a renamed/missing constant fails loudly (KeyError) instead of silently. - return {k.lower(): v for k, v in globals().items() if k.isupper() and isinstance(v, str)} + mapping = {k.lower(): v for k, v in globals().items() if k.isupper() and isinstance(v, str)} + if _font_scale != 1.0: + # ponytail: only the app-sheet FONT_* ladder scales; widgets that + # 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"): + mapping[k] = f"{round(int(v[:-2]) * _font_scale)}px" + return mapping def qcolor(color: str, alpha: int | None = None): @@ -1026,7 +1109,10 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: border-top-right-radius: 4px; padding: 4px 14px; margin-top: 3px; - color: $muted_text; + /* tab_idle_text, not muted_text: camera tabs sit on the sky + gradient where muted grey measured 2.07:1 (WCAG text floor is + 4.5:1); the darker slate is 4.71:1 there, 11.2:1 on panels. */ + color: $tab_idle_text; } QTabBar::tab:selected { @@ -1082,24 +1168,42 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: font-weight: 700; } - /* Idle resize lines: faint fill (10% of the hover alpha) so the drag - target is findable without hunting. This base fill replaces the - native dotted grip — traded away on purpose for discoverability. */ - QMainWindow::separator, QSplitter::handle { - background: $separator_idle; + /* Resize gutters: the mouse keeps the full $separator_region, but only + a 2px line + 1px shadow paints (gradients in _separator_gradient). + An upright line needs the x-gradient: that is a :vertical main-window + separator but a :horizontal splitter handle (handle orientation + follows the splitter, not the bar). */ + QMainWindow::separator { + width: $separator_region; + height: $separator_region; + background: transparent; } + QMainWindow::separator:vertical, QSplitter::handle:horizontal { + background: $sep_idle_x; + } + QMainWindow::separator:horizontal, QSplitter::handle:vertical { + background: $sep_idle_y; + } + QSplitter::handle:horizontal { width: $separator_region; } + QSplitter::handle:vertical { height: $separator_region; } /* Resize-line hint — the separatorHint property is flipped by MainWindow.event() after a 1s hover rest or on press; :hover limits the fill to the exact separator being dragged. */ - QMainWindow[separatorHint="true"]::separator:hover { - background: $separator_hint; + QMainWindow[separatorHint="true"]::separator:vertical:hover { + background: $sep_hint_x; + } + QMainWindow[separatorHint="true"]::separator:horizontal:hover { + background: $sep_hint_y; } /* Splitter handles (prediction metrics) are plain child widgets the property gate above doesn't reach — immediate hover/press hint. */ - QSplitter::handle:hover, QSplitter::handle:pressed { - background: $separator_hint; + QSplitter::handle:horizontal:hover, QSplitter::handle:horizontal:pressed { + background: $sep_hint_x; + } + QSplitter::handle:vertical:hover, QSplitter::handle:vertical:pressed { + background: $sep_hint_y; } QFrame#beamlineControls, @@ -1743,16 +1847,32 @@ def _sunset_stylesheet() -> str: } /* Idle + hover resize lines, dark flavor — see the light-theme note. */ - QMainWindow::separator, QSplitter::handle { - background: $dark_separator_idle; + QMainWindow::separator { + width: $separator_region; + height: $separator_region; + background: transparent; + } + QMainWindow::separator:vertical, QSplitter::handle:horizontal { + background: $dark_sep_idle_x; + } + QMainWindow::separator:horizontal, QSplitter::handle:vertical { + background: $dark_sep_idle_y; + } + QSplitter::handle:horizontal { width: $separator_region; } + QSplitter::handle:vertical { height: $separator_region; } + + QMainWindow[separatorHint="true"]::separator:vertical:hover { + background: $dark_sep_hint_x; + } + QMainWindow[separatorHint="true"]::separator:horizontal:hover { + background: $dark_sep_hint_y; } - QMainWindow[separatorHint="true"]::separator:hover { - background: $dark_accent; + QSplitter::handle:horizontal:hover, QSplitter::handle:horizontal:pressed { + background: $dark_sep_hint_x; } - - QSplitter::handle:hover, QSplitter::handle:pressed { - background: $dark_accent; + QSplitter::handle:vertical:hover, QSplitter::handle:vertical:pressed { + background: $dark_sep_hint_y; } /* Plain scroll containers stay frameless. */ @@ -1925,5 +2045,15 @@ if __name__ == "__main__": for _theme in (THEME_SUNRISE, THEME_SUNSET, THEME_BLUEBIRD): assert "$" not in build_app_stylesheet(_theme) assert APP_BACKGROUND not in build_app_stylesheet(THEME_BLUEBIRD) + # Font zoom: the FONT_* ladder must follow the scale, and the scale must + # clamp to its documented bounds. + set_font_scale(1.3) + assert "font-size: 21px" in build_app_stylesheet(THEME_SUNRISE) # title 16->21 + set_font_scale(99.0) + assert font_scale() == FONT_SCALE_MAX + set_font_scale(0.0) + assert font_scale() == FONT_SCALE_MIN + set_font_scale(1.0) + assert "font-size: 16px" in build_app_stylesheet(THEME_SUNRISE) # This line was added by Claude. But I would do the same. So all gude. print("gude") diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index e709e3ea..380424cc 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -2,8 +2,9 @@ from unittest.mock import MagicMock, patch import pytest from PySide6.QtCore import QSettings, Qt -from PySide6.QtWidgets import QDockWidget +from PySide6.QtWidgets import QApplication, QDockWidget +from aare.gui import styles from aare.gui.main_window import MainWindow from aare.gui.styles import THEME_BLUEBIRD, THEME_SUNRISE, THEME_SUNSET @@ -502,6 +503,50 @@ def test_theme_settings_migrate_and_slots_switch(qtbot, mock_ui_state): assert win._theme_mode == THEME_SUNRISE +def test_font_zoom_steps_clamps_and_resets(qtbot, mock_ui_state): + with ( + patch("requests.get"), + patch("aare.gui.main_window.DAQWorker"), + patch("aare.gui.main_window.PredictionSubscriber"), + patch("aare.gui.main_window.VideoThread"), + patch("aare.gui.main_window.JFJochDBusClient"), + patch("aare.gui.main_window.jwt.decode") as mock_jwt, + ): + mock_jwt.return_value = { + "sub": "testuser", + "staff": True, + "pgroups": ["p123"], + "session": 15, + } + win = _make_window(qtbot) + + settings = QSettings("PSI", "AareGUI") + saved = settings.value("appearance/font_scale") + try: + base_pt = win._default_app_font.pointSizeF() + win._change_font_zoom(1) + assert styles.font_scale() == pytest.approx(1.1) + # Part 2 of the zoom: the app default font scales with the ladder. + app = QApplication.instance() + assert isinstance(app, QApplication) # narrow from QCoreApplication|None + assert app.font().pointSizeF() == pytest.approx(base_pt * 1.1) + for _ in range(20): + win._change_font_zoom(1) + assert styles.font_scale() == styles.FONT_SCALE_MAX + win._change_font_zoom(0) + assert styles.font_scale() == 1.0 + assert app.font().pointSizeF() == pytest.approx(base_pt) + for _ in range(20): + win._change_font_zoom(-1) + assert styles.font_scale() == styles.FONT_SCALE_MIN + finally: + styles.set_font_scale(1.0) + if saved is None: + settings.remove("appearance/font_scale") + else: + settings.setValue("appearance/font_scale", saved) + + def test_restore_window_state_heals_all_hidden_docks(qtbot, mock_ui_state): with ( patch("requests.get"), -- 2.54.0 From 29ae65a100bc8a4f676ae77cec406a4606895385 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 14 Sep 2026 10:28:50 +0200 Subject: [PATCH 2/4] feat: show the mounted sample name on the camera view A "Currently mounted: " HUD line in the top-left corner of the sample camera, driven by the DAQ status (clears when nothing is mounted). Painted like the other camera HUDs and sized through the text zoom, which QSS cannot reach here. Co-Authored-By: Claude Fable 5 --- src/aare/gui/widgets/camera_image.py | 34 ++++++++++++++++++++++++++++ tests/unit/gui/test_camera_image.py | 27 ++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 03454282..04705f8b 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -59,6 +59,7 @@ from aare.gui.styles import ( THEME_SUNSET, TOOLTIP_TEXT, WHITE, + font_scale, qcolor, ) from aare.gui.widgets.busy_overlay import ( @@ -130,6 +131,7 @@ class SampleCameraImageLabel(QGraphicsView): self._tell_state = None self._auto_centering = False self._busy_overlay_style: BusyOverlayStyle | None = None + self._mounted_sample_name: str | None = None self._geom = geom self._bookmarks: SmargonBookmarkList = SmargonBookmarkList() @@ -459,6 +461,7 @@ class SampleCameraImageLabel(QGraphicsView): self._draw_detections(painter, rect) self._draw_target_point(painter) self._draw_overlay_legend(painter) + self._draw_mounted_sample(painter) self._draw_hover_hud(painter) self._draw_help_overlay(painter) @@ -803,6 +806,11 @@ class SampleCameraImageLabel(QGraphicsView): self._bounding_box = s.box self._tell_state = s.tell_state + new_name = s.sample.sample_name if s.sample is not None else None + if new_name != self._mounted_sample_name: + self._mounted_sample_name = new_name + self.viewport().update() + new_session_state = s.session.session if hasattr(s, "session") else None if new_session_state != self._session_state: self._session_state = new_session_state @@ -1269,6 +1277,32 @@ 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 _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. + if not self._mounted_sample_name: + return + + painter.save() + painter.resetTransform() + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + font = QFont() + # Follows the Ctrl+plus/minus text zoom — painted HUDs bypass QSS. + font.setPointSize(round(10 * font_scale())) + font.setBold(True) + painter.setFont(font) + fm = QFontMetrics(font) + + margin = 18 + text = f"Currently mounted: {self._mounted_sample_name}" + baseline = margin + fm.ascent() + painter.setPen(QPen(qcolor(SHADOW, 200))) + painter.drawText(QPointF(margin + 1, baseline + 1), text) + painter.setPen(QPen(qcolor(WHITE))) + painter.drawText(QPointF(margin, baseline), text) + painter.restore() + def _draw_hover_hud(self, painter: QPainter): # Bottom-right HUD: grey scale bar over the hovered pixel coordinates. if self._hover_pos is None or self.pixmap_item is None: diff --git a/tests/unit/gui/test_camera_image.py b/tests/unit/gui/test_camera_image.py index be91b41a..bf28c75e 100644 --- a/tests/unit/gui/test_camera_image.py +++ b/tests/unit/gui/test_camera_image.py @@ -297,3 +297,30 @@ def test_unknown_session_paints_the_viewing_mode_badge(camera): assert camera._busy_overlay_style is None camera.grab() assert camera._session_badge_rect is not None + + +def test_mounted_sample_hud_follows_status(camera): + from aarecommon.models.models import DewarAddress, SampleShortInfo + + # No sample in the status -> no HUD line, draw path skips cleanly. + assert camera._mounted_sample_name is None + camera.grab() + + sample = SampleShortInfo( + db_id=7, + puck_name="puck1", + dewar_name="dewar", + sample_name="lyso_007", + run_number=1, + user="p123", + pin=1, + location=DewarAddress(segment="A", pos=1), + ) + status = _status(busy=False, session=SessionsStateEnum.OwnedByYou) + camera.update_daq_status(status.model_copy(update={"sample": sample})) + assert camera._mounted_sample_name == "lyso_007" + camera.grab() # paints the top-left "Currently mounted:" line + + # Unmount (sample gone from the status) clears the line again. + camera.update_daq_status(status) + assert camera._mounted_sample_name is None -- 2.54.0 From 6b34fd80bbfc55a6f25eb46e40ba579ffc6f8880 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 14 Sep 2026 10:29:12 +0200 Subject: [PATCH 3/4] refactor: transmission is a per-mode setting, placed in its section The shared "Rotation transmission" row sat in the top rows of BOTH the Gridscan and Rotation tabs, where it read as one setting for everything (and as nonsense in the Gridscan tab). Now each tab places its own row: Gridscan keeps "Transmission" with the shared resolution/distance rows, and the Rotation tab moves "Rotation transmission" below the Screening block and its Run button, as the first row of the Rotation section - next to the fields it actually applies to. Screening keeps its separate "Screening transmission". No behavior change: same field, same spreadsheet/user-value handling, only the label and the placement. Co-Authored-By: Claude Fable 5 --- src/aare/gui/panels/raster_data_collection.py | 3 ++ .../gui/panels/rotation_data_collection.py | 21 +++++++---- src/aare/gui/panels/scan_settings_panel.py | 7 +++- .../unit/gui/test_data_collection_settings.py | 37 +++++++++++++++++++ 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/aare/gui/panels/raster_data_collection.py b/src/aare/gui/panels/raster_data_collection.py index bbbe74b4..9aee838a 100644 --- a/src/aare/gui/panels/raster_data_collection.py +++ b/src/aare/gui/panels/raster_data_collection.py @@ -30,6 +30,9 @@ class RasterDataCollectionPanel(ScanSettingsPanel): diffraction=diffraction, default_dtz=raster_mgr.active_grid.dtz, default_transmission=raster_mgr.active_grid.transmission, + # Plain "Transmission": the tab IS the gridscan, and the longer + # "Gridscan transmission" clips in the label column. + transmission_label="Transmission", parent=parent, ) diff --git a/src/aare/gui/panels/rotation_data_collection.py b/src/aare/gui/panels/rotation_data_collection.py index 8c7ca3b2..c405380e 100644 --- a/src/aare/gui/panels/rotation_data_collection.py +++ b/src/aare/gui/panels/rotation_data_collection.py @@ -61,6 +61,11 @@ class RotationDataCollectionPanel(ScanSettingsPanel): "daq.data_collection_settings.default_rotation_settings.transmission", default_transmission, ), + # Row 11 = first row under the "Rotation" header: the rotation + # transmission belongs next to the rotation fields, not at the + # top of the tab where it read as applying to screening too. + transmission_row=11, + transmission_label="Rotation transmission", ) self._filename = "" @@ -151,13 +156,13 @@ class RotationDataCollectionPanel(ScanSettingsPanel): self.total_angle = NumberLineEdit( 0, 9999.0, self._default_total_angle, decimals=3, parent=self, track_pending=True ) - self._add_row(11, "Total angle", self.total_angle, "°") + self._add_row(12, "Total angle", self.total_angle, "°") self._add_database_field(self.total_angle, self._on_total_angle_committed) self.image_angle = NumberLineEdit( 0, 10.0, self._default_image_angle, decimals=3, parent=self, track_pending=True ) - self._add_row(12, "Image angle", self.image_angle, "°") + self._add_row(13, "Image angle", self.image_angle, "°") self._add_database_field(self.image_angle, self._on_image_angle_committed) self.image_time_enter = NumberLineEdit( @@ -168,27 +173,27 @@ class RotationDataCollectionPanel(ScanSettingsPanel): parent=self, track_pending=True, ) - self._add_row(13, "Image time", self.image_time_enter, "s") + self._add_row(14, "Image time", self.image_time_enter, "s") self._add_database_field(self.image_time_enter, self._on_exp_time_committed) self.omega_speed = QLabel("-") - self._add_row(14, "Rotation speed", self.omega_speed, "°/s") + self._add_row(15, "Rotation speed", self.omega_speed, "°/s") self.total_time = QLabel(f"{self._total_time} min 0 s") - self._add_row(15, "Total measurement time", self.total_time) + self._add_row(16, "Total measurement time", self.total_time) self.dose = QLabel(f"{self._dose_mgy}") - self._add_row(16, "Dose", self.dose, "MGy") + self._add_row(17, "Dose", self.dose, "MGy") self.measurement_button = QPushButton("Run rotation") self.measurement_button.setStyleSheet(f"color: {GO_TEXT};") self.measurement_button.clicked.connect(self.run_measurement) - self._layout.addWidget(self.measurement_button, 17, 0, 1, 6) + self._layout.addWidget(self.measurement_button, 18, 0, 1, 6) # Per-tab Abort (DataCollectionSettings wires it to the DAQ cancel). self.abort_button = QPushButton("Abort measurement") self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};") - self._layout.addWidget(self.abort_button, 18, 0, 1, 6) + self._layout.addWidget(self.abort_button, 19, 0, 1, 6) # every field exists now: fill in the speed and measurement time self._values_changed() diff --git a/src/aare/gui/panels/scan_settings_panel.py b/src/aare/gui/panels/scan_settings_panel.py index 17d369c0..a60c3fc4 100644 --- a/src/aare/gui/panels/scan_settings_panel.py +++ b/src/aare/gui/panels/scan_settings_panel.py @@ -125,6 +125,8 @@ class ScanSettingsPanel(QWidget): diffraction: DiffractionGeometry, default_dtz: float = 200.0, default_transmission: float = 1.0, + transmission_row: int = 2, + transmission_label: str = "Transmission", parent=None, ): super().__init__(parent) @@ -186,10 +188,13 @@ class ScanSettingsPanel(QWidget): self._add_row(1, "Detector distance", self.dtz_enter, "mm") self._add_database_field(self.dtz_enter, self._on_dtz_committed) + # Placed where the subclass says: transmission is a per-mode setting + # (user request), so each tab puts its own row next to its section + # instead of a shared top-level "Rotation transmission" for all tabs. self.transmission_enter = NumberLineEdit( 0, 1.0, default=default_transmission, decimals=4, parent=self, track_pending=True ) - self._add_row(2, "Rotation transmission", self.transmission_enter) + self._add_row(transmission_row, transmission_label, self.transmission_enter) self._add_database_field(self.transmission_enter, self._on_transmission_committed) # -- grid rows ---------------------------------------------------------- diff --git a/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py index d821e942..1d2ca541 100644 --- a/tests/unit/gui/test_data_collection_settings.py +++ b/tests/unit/gui/test_data_collection_settings.py @@ -20,6 +20,7 @@ from aarecommon.models.models import ( SampleShortInfo, ) from PySide6.QtCore import Qt +from PySide6.QtWidgets import QLabel from aare.gui.panels.data_collection_settings import DataCollectionSettings from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel @@ -448,3 +449,39 @@ def test_energy_spin_motor_move_semantics(settings_panel, daq_status_factory): settings_panel._energy_state.update_actual(12.3995) assert box.property("movestate") == "" + + +def _grid_widget(grid, row: int, col: int): + item = grid.itemAtPosition(row, col) + assert item is not None + widget = item.widget() + assert widget is not None + return widget + + +def test_transmission_rows_are_per_mode(panel, qapp, diffraction): + # Rotation tab: the rotation transmission moved out of the shared top + # rows into the Rotation section (row 11, right under the header), below + # the Screening block and its Run button (row 9). + grid = panel._layout + label = _grid_widget(grid, 11, 0) + assert isinstance(label, QLabel) + assert label.text() == "Rotation transmission" + assert _grid_widget(grid, 11, 1) is panel.transmission_enter + assert _grid_widget(grid, 9, 0) is panel.screening_button + + # Gridscan tab keeps its own transmission in the top rows, without the + # misleading "Rotation" prefix. + geom = SampleGeometryModel( + beam_location_pxl=Coordinate(x=500, y=500), + pixel_in_mm=0.001, + aerotech=Coordinate(), + aerotech_meas=Coordinate(), + smargon=SmargonCoordinate(sh_mm=Coordinate(), phi_deg=0.0, chi_deg=0.0), + omega_deg=0.0, + beam_size_mm=Coordinate(x=0.01, y=0.01), + ) + raster = RasterDataCollectionPanel(RasterGridManager(geom), diffraction) + raster_label = _grid_widget(raster._layout, 2, 0) + assert isinstance(raster_label, QLabel) + assert raster_label.text() == "Transmission" -- 2.54.0 From 27fa6cb260b65547dcfc7a853360e17763211c5c Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 14 Sep 2026 10:38:30 +0200 Subject: [PATCH 4/4] feat: full-name tooltips on the glyph count column headers The sample list's Mount/Gridscan/Screening/Rotation count columns show only a glyph in the header to save width; hovering now shows the full name via ToolTipRole. Text headers stay tooltip-free. Co-Authored-By: Claude Fable 5 --- src/aare/gui/models/user_sample_model.py | 12 ++++++++++++ tests/unit/gui/test_models.py | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/aare/gui/models/user_sample_model.py b/src/aare/gui/models/user_sample_model.py index 9751862f..866cd6c2 100644 --- a/src/aare/gui/models/user_sample_model.py +++ b/src/aare/gui/models/user_sample_model.py @@ -1,4 +1,5 @@ import re +from typing import ClassVar from aarecommon.config.logger import setup_logger from aarecommon.models.models import SampleShortInfo, SampleShortInfoList @@ -235,12 +236,23 @@ class UserSampleSpreadsheet(QAbstractTableModel): [Qt.ItemDataRole.BackgroundRole, Qt.ItemDataRole.ForegroundRole], ) + # Hover tooltips for the glyph-only count columns — the icons save header + # width but don't explain themselves. + HEADER_TOOLTIPS: ClassVar[dict[str, str]] = { + "⧂": "Mount count", + "▦": "Gridscan count", + "⌕": "Screening count", + "↻": "Rotation count", + } + def headerData(self, section, orientation, role=None): if role == Qt.ItemDataRole.DisplayRole: if orientation == Qt.Orientation.Horizontal: # Column header return self.header[section] if self.header else f"Column {section + 1}" if orientation == Qt.Orientation.Vertical: # Row header return str(section + 1) # Row numbers start from 1 + if role == Qt.ItemDataRole.ToolTipRole and orientation == Qt.Orientation.Horizontal: + return self.HEADER_TOOLTIPS.get(self.header[section]) return None def updateCurrentSample( diff --git a/tests/unit/gui/test_models.py b/tests/unit/gui/test_models.py index e11ec6a6..56c215d4 100644 --- a/tests/unit/gui/test_models.py +++ b/tests/unit/gui/test_models.py @@ -304,3 +304,21 @@ def test_spreadsheet_param_columns(status_model): model.sort(osc, Qt.SortOrder.AscendingOrder) assert model.get_id(0).db_id == 1 assert model.headerData(hdr.index("Comment"), Qt.Orientation.Horizontal, 0) == "Comment" + + +def test_glyph_count_headers_have_full_name_tooltips(status_model): + model = status_model + tip = lambda glyph: model.headerData( + model.header.index(glyph), Qt.Orientation.Horizontal, Qt.ItemDataRole.ToolTipRole + ) + assert tip("⧂") == "Mount count" + assert tip("▦") == "Gridscan count" + assert tip("⌕") == "Screening count" + assert tip("↻") == "Rotation count" + # Text headers explain themselves: no tooltip. + assert ( + model.headerData( + model.header.index("Comment"), Qt.Orientation.Horizontal, Qt.ItemDataRole.ToolTipRole + ) + is None + ) -- 2.54.0