feat: text zoom shortcuts and high-contrast resize lines
CI / lint (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped
CI / lint (pull_request) Successful in 1m0s
CI / test (3.12) (pull_request) Successful in 1m27s
CI / test (3.14) (pull_request) Successful in 1m24s
CI / test (3.13) (pull_request) Successful in 1m36s
CI / test-with-beamline-plugins (pxi_bec) (pull_request) Successful in 1m34s
CI / test-with-beamline-plugins (pxiii_bec) (pull_request) Successful in 1m35s
CI / test-with-beamline-plugins (pxii_bec) (pull_request) Successful in 1m39s
CI / test-with-coverage (pull_request) Successful in 1m53s
CI / coverage-analysis (pull_request) Successful in 4s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 22:21:46 +02:00
co-authored by Claude Fable 5
parent a177484e61
commit bd74ac47bc
3 changed files with 252 additions and 33 deletions
+44
View File
@@ -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()
+162 -32
View File
@@ -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")
+46 -1
View File
@@ -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"),