feat: GUI accessibility - text zoom, high-contrast resize lines, per-mode transmission #224
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ----------------------------------------------------------
|
||||
|
||||
+162
-32
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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"),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user