From bd74ac47bc14e35c98146307d008d83cacf44644 Mon Sep 17 00:00:00 2001 From: Dawn Date: Sun, 13 Sep 2026 22:21:34 +0200 Subject: [PATCH] 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"),