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/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/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/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/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 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" 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"), 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 + )