Files
AareDAQ/src/aare/gui/styles.py
T
duan_jandClaude Fable 5 809959df33 feat!: remove Playlist Mode and Automation View, unpin window minimum
Why the width shrink only worked after a Playlist Mode round-trip:
Qt auto-pins a top-level window's minimum size to its layout minimum,
and returning from Playlist Mode hid the Information dock (and every
side dock), which happened to drop that layout minimum. The main view
now gets an explicit small minimum (320x240) which disables the
auto-pin outright, so the window shrinks directly and the dock
contents clip or scroll instead.

The Playlist Mode (Ctrl+6) and Automation View (Ctrl+5) prototypes
are deleted per user decision: panels, pages, the content stack (the
standard page is the only page left), menu actions, camera fan-out to
their view copies, portrait alert interceptors (now plain runtime
notification routers), and their styles (portraitRoot, compact*
cards, COMPACT_* and FONT_HERO knobs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-16 13:28:16 +02:00

1831 lines
67 KiB
Python

from __future__ import annotations
from pathlib import Path
from string import Template
# These string values are the tokens persisted in QSettings("appearance/theme").
# They were renamed from "original"/"portrait"; MainWindow._restore_theme_settings
# migrates the old tokens so a user's saved theme survives the rename.
THEME_SUNRISE = "sunrise"
THEME_SUNSET = "sunset"
# Sunrise with the sky gradient flattened to its top color — for consoles
# where the gradient banding distracts, and as a plain-background baseline.
THEME_BLUEBIRD = "bluebird"
# ---------------------------------------------------------------------------
# Color palette. Change values HERE to try a different look — the QSS below
# and the widgets that import these use them everywhere. Every UPPERCASE
# constant is exposed to the QSS templates as $lowercase_name (see _palette),
# so no color literals live inside the stylesheets themselves.
# Any value can be "transparent" (e.g. to hide a border or a fill).
# string.Template ($name) instead of f-strings so the QSS braces stay literal.
# -- Light theme ------------------------------------------------------------
BACKGROUND = "#e2e7ee"
# App-wide sunrise-sky gradient (sampled from the reference photo taken at dawn
# near Dawn's house at Windisch: slate blue fading through pale grey-lavender
# into warm cream. Painted once per top-level window (QMainWindow/QDialog)
# while plain child widgets stay transparent, so the window reads as ONE
# continuous sky instead of every widget restarting the gradient.
# Tune the transition point here:
BACKGROUND_GRADIENT_TOP = "#84abd9" # RHEL9 window-frame blue (sampled from screenshot)
BACKGROUND_GRADIENT_MID = "#adccf1"
BACKGROUND_GRADIENT_MID_POS = "0.55" # 0..1 — where the mid stop sits
BACKGROUND_GRADIENT_BOTTOM = "#f8e9c5"
APP_BACKGROUND = (
"qlineargradient(x1:0, y1:0, x2:0, y2:1,"
f" stop:0 {BACKGROUND_GRADIENT_TOP},"
f" stop:{BACKGROUND_GRADIENT_MID_POS} {BACKGROUND_GRADIENT_MID},"
f" stop:1 {BACKGROUND_GRADIENT_BOTTOM})"
)
BANNER = "#a2b7e4"
BANNER_TEXT = "#F8F8FC" # must contrast with BANNER
BANNER_TEXT_SHADOW = "#000000" # soft shadow under banner titles; alpha in TitleLabel
# Banner edge lines (1px left + bottom): each line fades in from transparent,
# peaks at BANNER_EDGE mid-line and fades out at the far end — a soft sheen,
# not a hard rule. BANNER_EDGE is the peak color; "transparent" hides both.
BANNER_EDGE = "#ffffff"
BANNER_EDGE_H = ( # bottom line — fades along the banner width
"qlineargradient(x1:0, y1:0, x2:1, y2:0,"
f" stop:0 transparent, stop:0.5 {BANNER_EDGE}, stop:1 transparent)"
)
BANNER_EDGE_V = ( # left line — fades along the banner height
"qlineargradient(x1:0, y1:0, x2:0, y2:1,"
f" stop:0 transparent, stop:0.5 {BANNER_EDGE}, stop:1 transparent)"
)
TEXT = "#263043"
SURFACE = "#f2f2f2" # input fields, cards, group boxes
# Buttons: flat + hairline like the dark theme (the explicit border is what
# switches Qt from bulky native chrome to compact QSS box rendering).
# Half-transparent so the sky gradient shimmers through, same glass idea as
# INPUT_BG — slightly more solid so clickables read as raised faces.
BUTTON_BG = "rgba(255, 255, 255, 50%)"
# Hover: a dark ink tint instead of more white — darkens whatever sky shade
# is behind the button (the dark theme hovers LIGHTER, see DARK_ELEVATED_HOVER).
BUTTON_BG_HOVER = "rgba(76, 79, 105, 18%)"
BUTTON_BORDER = "#c9cfd8" # same tone as FRAME_L3_COLOR, separate knob
# Spin/combo arrow glyphs: tiny PNGs — this Qt draws neither native glyphs
# nor QSS border-triangles inside styled spin buttons, so image files are
# the only reliable path. Colors live in the PNGs (TEXT / DARK_TEXT at
# generation time); regenerate them if those knobs change.
_GRAPHICS_DIR = Path(__file__).resolve().parent / "graphics"
SPIN_ARROW_UP = (_GRAPHICS_DIR / "spin_arrow_up_light.png").as_posix()
SPIN_ARROW_DOWN = (_GRAPHICS_DIR / "spin_arrow_down_light.png").as_posix()
DARK_SPIN_ARROW_UP = (_GRAPHICS_DIR / "spin_arrow_up_dark.png").as_posix()
DARK_SPIN_ARROW_DOWN = (_GRAPHICS_DIR / "spin_arrow_down_dark.png").as_posix()
# Slider-handle grip lines (3 vertical ticks) — QSS can't draw interior
# lines, so they are tiny PNG assets like the spin arrows above.
SLIDER_GRIP = (_GRAPHICS_DIR / "slider_grip_light.png").as_posix()
DARK_SLIDER_GRIP = (_GRAPHICS_DIR / "slider_grip_dark.png").as_posix()
# Check marks (PRIMARY blue / dusk gold at generation time):
CHECK_MARK = (_GRAPHICS_DIR / "check_mark_light.png").as_posix()
DARK_CHECK_MARK = (_GRAPHICS_DIR / "check_mark_dark.png").as_posix()
# Resize-bar grips: IDLE paints a short (66px) centered bar instead of a
# full-length line — the full idle line read as a wall between panels; the
# hover/drag hint keeps the full-length gradient (see _separator_gradient).
# QSS can't paint a fixed-length centered segment, so PNGs like the arrows
# above; used by BOTH QMainWindow::separator and QSplitter::handle, so this
# is the one place. Colors baked in (SEPARATOR_IDLE / DARK_SEPARATOR_IDLE)
# — regenerate if those knobs change.
GRIP_IDLE_V = (_GRAPHICS_DIR / "resize_grip_v_idle_light.png").as_posix()
GRIP_IDLE_H = (_GRAPHICS_DIR / "resize_grip_h_idle_light.png").as_posix()
DARK_GRIP_IDLE_V = (_GRAPHICS_DIR / "resize_grip_v_idle_dark.png").as_posix()
DARK_GRIP_IDLE_H = (_GRAPHICS_DIR / "resize_grip_h_idle_dark.png").as_posix()
# Borders (all can be "transparent" to hide the line):
BORDER = "transparent" # main dividers, e.g. the beamline state bar top line
CARD_BORDER = "transparent" # cards / group boxes (Local Contact, automation)
# -- Box-frame borders by nesting level -------------------------------------
# One knob per level so the amount of "boxing" is tunable in one place:
# L1: top-level pop-out window edge (painted by PopoutWindow)
# L2: panel frames inside a dock/tab (TELL sample changer, reference
# tools, sample queue) — transparent by default: banner + table
# already delimit the panel, extra boxes read as clutter
# L3: data views (tables, log text) — a light line so scrollable content
# keeps a visible edge
# (The tab-widget pane is a fourth box, already governed by BORDER above.)
FRAME_L1_WIDTH = "0px" # default off — the pop-out reads as one clean surface
FRAME_L1_COLOR = "#bdbdbd"
FRAME_L2_WIDTH = "0px"
FRAME_L2_COLOR = "transparent"
FRAME_L3_WIDTH = "1px"
FRAME_L3_COLOR = "#c9cfd8"
# Left inset (px, int — used in code, not QSS) for bottom-dock content so its
# left edge lines up with the left-column panels above (Loop centering).
DOCK_CONTENT_LEFT_PAD = 10
# Gap (px, int — used in code, not QSS) between a TitleLabel banner and a tab
# bar sitting directly under it (Exp. Config.), so the tabs don't touch the
# banner's bottom edge line.
BANNER_TAB_GAP = 6
# 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; idle paints only the
# short GRIP_* bar (a full-length idle line read as a wall), hover/drag
# brings back the full-length hint gradient so the grabbed bar reads over
# its whole run. 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:
"""Full-length hover/drag hint 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_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
# Tab face fill: the selected Dewar/Auxiliary tab AND the unchecked
# All/Queued/Flagged/Measured filter buttons share this background.
TAB_FACE_BG = "#ffffff"
# Selection highlight (combo popups, menus, list rows, text selections).
# Qt's style palette used to supply its own blue here; now it is the banner
# blue, and THIS is the knob for it. (Table-row selection has its own paler
# knob: SAMPLE_STATUS_SELECTED_BG.)
SELECTION_BG = BANNER
SELECTION_TEXT = "#263043" # same as TEXT — readable on the banner blue
PRIMARY = "#2563eb" # main action button
PRIMARY_HOVER = "#1d4ed8"
PRIMARY_TEXT = "white"
SECONDARY_BG = "#dfe9fb"
SECONDARY_BG_HOVER = "#d3e1f8"
# Alert banners (alertKind: error / success / waiting=warning):
# Catppuccin Latte: BG = 15% accent over base, border = 50%, text = 65% over
# Latte text — same recipe as the chips/cards/log blocks below.
ERROR_BG = "#ebcfd9" # red wash
ERROR_BORDER = "#e08097"
ERROR_TEXT = "#a3254a"
SUCCESS_BG = "#d5e5d7" # green wash
SUCCESS_BORDER = "#98c890"
SUCCESS_TEXT = "#448441"
WARNING_BG = "#f1dcd2" # peach wash
WARNING_BORDER = "#f6aa80"
WARNING_TEXT = "#c05d2c"
# Axis video status + beamline state bar:
STATUS_IDLE_BG = "#d9e2f2"
STATUS_LABEL_TEXT = "#2f3b52"
STATE_TOGGLE_TEXT = "white" # collapse glyph sitting on the BANNER strip
STATE_CURRENT_TEXT = "#1e293b" # same slate as HEADING_TEXT, separate knob
STATE_TELL_TEXT = "#374357" # also the status-bar TELL line
# -- Sunset (dark) theme: Daemmerung DUSK palette ---------------------------
# Adopted from ~/repos/aaregui2 (focus/theme.py, jdawnduan.com Daemmerung).
# Grouped by ROLE, mirroring the source Palette dataclass, so a later change
# touches one block. The site's translucent glass is flattened to opaque hex
# (this QSS paints widgets opaque); the radial sunset backdrop is NOT
# adopted — it needs the transparent-children scheme the Sunrise theme uses.
# Surfaces:
DARK_BG = "#15213a" # bg — window backdrop
# Sunset-sky backdrop (reference photo): near-black navy zenith; the fade
# begins at MID_POS (0.55, same as the day theme), runs through the blue
# band low in the window, and the warm glow is squeezed into the last
# stretch below BLUE_POS. Warm tone deliberately dimmer than the photo's
# cream — dusk text is light. Same transparent-children scheme as
# APP_BACKGROUND; set all stops to DARK_BG for a flat backdrop.
DARK_BACKGROUND_GRADIENT_TOP = "#0c1a33"
DARK_BACKGROUND_GRADIENT_MID = "#16294d" # fade onset tone
DARK_BACKGROUND_GRADIENT_MID_POS = "0.55" # 0..1 — where the fade begins
DARK_BACKGROUND_GRADIENT_BLUE = "#2c5f9e" # the blue band
DARK_BACKGROUND_GRADIENT_BLUE_POS = "0.90" # 0..1 — warm glow only below this
DARK_BACKGROUND_GRADIENT_BOTTOM = "#2c5f9e" # the warm glow doesn't seem to fit
DARK_APP_BACKGROUND = (
"qlineargradient(x1:0, y1:0, x2:0, y2:1,"
f" stop:0 {DARK_BACKGROUND_GRADIENT_TOP},"
f" stop:{DARK_BACKGROUND_GRADIENT_MID_POS} {DARK_BACKGROUND_GRADIENT_MID},"
f" stop:{DARK_BACKGROUND_GRADIENT_BLUE_POS} {DARK_BACKGROUND_GRADIENT_BLUE},"
f" stop:1 {DARK_BACKGROUND_GRADIENT_BOTTOM})"
)
DARK_PANEL2 = "#0e1728" # panel2 — deepest opaque: menus, popups, tooltips
DARK_SURFACE = "#1c2b4a" # panel — cards, state panel, scrollbar track
DARK_ELEVATED = "#253148" # glass2 (7% white) flattened — buttons, banners
DARK_ELEVATED_HOVER = "#32405c" # one glass step lighter — button hover
# Half the button glass (7% -> 4% white), translucent so the sky shows
# through: inputs sank to wells, buttons stay raised faces — same
# button-vs-input split the light theme makes with INPUT_BG vs BUTTON_BG.
DARK_INPUT_BG = "rgba(255, 255, 255, 4%)"
# Solid, not transparent: scroll-area viewports don't composite the window
# gradient on the container's X11 and render BLACK instead. Lighter sky-navy
# so tables don't read near-black against the backdrop.
DARK_TABLE_BG = "#263a61"
# Hairlines — the site's gold line flattened over bg at its three alphas:
DARK_BORDER_FAINT = "#31313b" # border (14%) — input/button edges
DARK_BORDER = "#423a3c" # border2 (22%) — cards, scrollbar handle
DARK_BORDER_STRONG = "#624c3d" # border3 (38%) — emphasized edges
# Text ramp (bright -> dim):
DARK_TEXT = "#e9edf4" # text — primary
DARK_SUBTEXT = "#aebccd" # subtext — secondary labels, hints
DARK_MUTED = "#7e8ea4" # muted — tertiary, unselected tabs
DARK_OVERLAY = "#66778f" # overlay — disabled text
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_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_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)
DARK_ON_ACCENT = "#1a1320" # text on either accent fill
# Banner strips (TitleLabel + beamline state title): gold edge sheen that
# fades in/out like the light theme's BANNER_EDGE_H/V.
DARK_BANNER_EDGE = "#e0913f"
DARK_BANNER_EDGE_H = (
"qlineargradient(x1:0, y1:0, x2:1, y2:0,"
f" stop:0 transparent, stop:0.5 {DARK_BANNER_EDGE}, stop:1 transparent)"
)
DARK_BANNER_EDGE_V = (
"qlineargradient(x1:0, y1:0, x2:0, y2:1,"
f" stop:0 transparent, stop:0.5 {DARK_BANNER_EDGE}, stop:1 transparent)"
)
# Status — border/text full strength, bg = 25% blend over DARK_BG (the dusk
# palette defines no alert backgrounds, so these are computed blends):
DARK_ERROR_BG = "#48374d" # alarm 25% over bg
DARK_ERROR_BORDER = "#e07a85" # alarm
DARK_ERROR_TEXT = "#e07a85" # alarm
DARK_SUCCESS_BG = "#2f4e5d" # green 25% over bg
DARK_SUCCESS_BORDER = "#7dd6c6" # green
DARK_SUCCESS_TEXT = "#7dd6c6" # green
DARK_WARNING_BG = "#473741" # warn (copper) 25% over bg
DARK_WARNING_BORDER = "#dd7a56" # warn
DARK_WARNING_TEXT = "#dd7a56" # warn
# -- Shared chrome (light-theme widgets) ------------------------------------
# Extracted from per-widget literals so the whole app is themeable from this
# file. The same hex may appear under two names when the roles differ —
# separate knobs on purpose.
WHITE = "#ffffff"
# ONLY for theme-independent light surfaces (tutorial callout). To "reset" a
# themed label, clear its stylesheet ("") so the theme color applies — a
# hardcoded black reset is invisible in the dark theme.
DEFAULT_TEXT = "#4c4f69" # latte text
NOTE_TEXT = "#5c5f77" # tutorial hints, TELL sample details (latte subtext1)
DIM_TEXT = "#6c6f85" # baton dialog timers (latte subtext0)
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
# -- Semantic action colors -------------------------------------------------
GO_TEXT = "#40a02b" # green start/run/measure button text
ABORT_TEXT = "#d20f39" # abort button text (red)
ALERT_TEXT = "#d20f39" # out-of-range motor labels (red)
PATH_WARN_TEXT = "#e64553" # file-exists warning in path panel (maroon)
DANGER_ACCENT = "#e64553" # invalid p-group border + message text (maroon)
# -- Status chips (local contact status) — "good" reuses SUCCESS_BG/TEXT ----
CHIP_WARN_BG = "#ede2d5" # yellow wash
CHIP_WARN_TEXT = "#ac7838"
CHIP_BAD_BG = "#ebcfd9" # red wash
CHIP_BAD_TEXT = "#a3254a"
CHIP_NEUTRAL_BG = "#e6e9ef" # latte mantle; text uses MUTED_TEXT
CHIP_INFO_BG = "#d0dcf5" # blue wash
CHIP_INFO_TEXT = "#2e5ec4"
# -- Status cards (beamline recovery, local contact error frame) ------------
WARN_CARD_BORDER = "#e9cea9" # yellow border
BAD_CARD_BORDER = "#e5a2b3" # red border
INFO_CARD_BG = "#d0dcf5" # blue wash
INFO_CARD_BORDER = "#a6c0f5"
PENDING_CARD_BG = "#ede2d5" # yellow wash
PENDING_CARD_BORDER = "#e9cea9"
# -- Log panel --------------------------------------------------------------
LOG_BORDER = "#8c8fa1" # latte overlay1
LOG_PANEL_BG = "#ecdae2" # faint red wash
LOG_ERROR_BG = "#ebcfd9"
LOG_ERROR_BORDER = "#e08097"
LOG_WARN_BG = "#ede2d5"
LOG_WARN_BORDER = "#e7c089"
LOG_SUCCESS_BG = "#d5e5d7"
LOG_SUCCESS_BORDER = "#98c890"
LOG_INFO_BG = "#d0dcf5"
LOG_INFO_BORDER = "#86acf5"
# -- Automation panel + progress steps --------------------------------------
AUTOMATION_TITLE_TEXT = "#4c4f69" # latte text
AUTOMATION_HINT_TEXT = "#5c5f77" # latte subtext1
STEP_RUNNING_TEXT = "#1e66f5" # latte blue — same as PRIMARY, separate knob
STEP_SUCCESS_TEXT = "#40a02b" # green
STEP_FAILED_TEXT = "#d20f39" # red
STEP_PAUSED_TEXT = "#c05d2c" # peach ink
STEP_DONE_BG = "#d5e5d7" # green wash
STEP_DONE_TEXT = "#448441"
STEP_DONE_BORDER = "#b2d5ae"
STEP_ACTIVE_BG = "#d0dcf5" # blue wash
STEP_ACTIVE_TEXT = "#2e5ec4"
STEP_ACTIVE_BORDER = "#a6c0f5"
STEP_FAILED_BG = "#ebcfd9" # red wash
STEP_FAILED_BORDER = "#e5a2b3"
STEP_PAUSED_BG = "#f1dcd2" # peach wash
STEP_PAUSED_BORDER = "#f4c0a3"
STEP_IDLE_BG = "#eff1f5" # latte base
STEP_IDLE_BORDER = "#dce0e8" # latte crust
# -- Baton request dialog ---------------------------------------------------
# Hover/pressed are the accent mixed 12%/24% toward Latte text.
BATON_OK_BG = "#40a02b" # green
BATON_OK_HOVER = "#419632"
BATON_OK_PRESSED = "#438d3a"
BATON_DANGER_BG = "#d20f39" # red
BATON_DANGER_HOVER = "#c2173f"
BATON_DANGER_PRESSED = "#b21e45"
BATON_WARN = "#fe640b" # peach
BATON_INFO = "#1e66f5" # blue
LIGHT_BORDER = "#bcc0cc" # latte surface1
PROGRESS_TRACK_BG = "#e6e9ef" # latte mantle
# -- Splash screen ----------------------------------------------------------
SPLASH_BG = "#dce0e8" # latte crust (progress-bar track)
SPLASH_BORDER = "#bcc0cc" # latte surface1
SPLASH_ACCENT = "#1e66f5" # latte blue
SPLASH_TEXT = "#4c4f69" # latte text — bar % and loading message
# -- Numeric inputs ---------------------------------------------------------
# Translucent, not solid white: the sky gradient shimmers through the field
# while text stays on a light ground. Raise the % for a more solid face.
# Why 16%: half of BUTTON_BG's 50% — at 33% inputs read as clickable button
# faces; the deeper glass keeps them visually distinct as wells, not buttons.
INPUT_BG = "rgba(255, 255, 255, 16%)"
INPUT_INVALID_BG = "#e9c4cf" # red 20% over latte base
INPUT_DISABLED_BG = "#e6e9ef" # latte mantle
INPUT_DISABLED_INVALID_BG = "#ecdae2" # faint red wash
# Motor batch-move states (MotorMoveGroup "movestate" dynamic property):
# staged-but-not-sent target vs motor in motion — see motor_move_group.py.
INPUT_PENDING_BG = "#f2cdba" # peach 25% over latte base
INPUT_MOVING_BG = "#c3ddc3" # green 25% over latte base
DARK_INPUT_PENDING_BG = "#473741" # warn (copper) 25% over bg
DARK_INPUT_MOVING_BG = "#2f4e5d" # green 25% over bg
# -- Status bar flags (Catppuccin Latte) ------------------------------------
STATUS_OK = "#40a02b" # closed / idle / owned / tell ok (green)
STATUS_ALERT = "#d20f39" # open / busy / other-owner / hot cryo (red)
STATUS_WARN = "#fe640b" # baton waiting / warming cryo / tell busy (peach)
STATUS_INFO = "#1e66f5" # cold cryo (blue)
STATUS_VACANT = "#df8e1d" # baton vacant (yellow)
STATUS_REQUEST = "#04a5e5" # baton request (sky)
# Dark variants (Catppuccin Mocha) — the latte values above sink into the
# sunset sky. Painted in code per DAQ tick, so status_colors(theme) hands
# them out — same pattern as state_colors below.
DARK_STATUS_OK = "#a6e3a1" # mocha green
DARK_STATUS_ALERT = "#f38ba8" # mocha red
DARK_STATUS_WARN = "#fab387" # mocha peach
DARK_STATUS_INFO = "#89b4fa" # mocha blue
DARK_STATUS_VACANT = "#f9e2af" # mocha yellow
DARK_STATUS_REQUEST = "#89dceb" # mocha sky
DARK_STATE_TELL_TEXT = "#bac2de" # mocha subtext1 — status-bar TELL idle line
def status_colors(theme: str) -> dict[str, str]:
"""Status-bar flag colors for the given theme (painted in code)."""
if theme == THEME_SUNSET:
return {
"ok": DARK_STATUS_OK,
"alert": DARK_STATUS_ALERT,
"warn": DARK_STATUS_WARN,
"info": DARK_STATUS_INFO,
"vacant": DARK_STATUS_VACANT,
"request": DARK_STATUS_REQUEST,
"tell": DARK_STATE_TELL_TEXT,
}
return {
"ok": STATUS_OK,
"alert": STATUS_ALERT,
"warn": STATUS_WARN,
"info": STATUS_INFO,
"vacant": STATUS_VACANT,
"request": STATUS_REQUEST,
"tell": STATE_TELL_TEXT,
}
# -- Beamline state panel ---------------------------------------------------
# The panel paints these in code per DAQ tick (data-driven), so it asks
# state_colors(theme) below instead of QSS. Light values unchanged; dark
# values are Catppuccin Mocha so they stay readable on the sunset sky.
STATE_AVAILABLE = "#ed8936"
STATE_UNAVAILABLE = "#8c96a5"
STATE_MSG_ERROR = "#c81e1e"
STATE_MSG_INFO = "#005caa"
DARK_STATE_AVAILABLE = "#fab387" # mocha peach
DARK_STATE_UNAVAILABLE = "#7f849c" # mocha overlay1
DARK_STATE_MSG_ERROR = "#f38ba8" # mocha red
DARK_STATE_MSG_INFO = "#89b4fa" # mocha blue
def state_colors(theme: str) -> dict[str, str]:
"""Beamline-state text colors for the given theme."""
if theme == THEME_SUNSET:
return {
"available": DARK_STATE_AVAILABLE,
"unavailable": DARK_STATE_UNAVAILABLE,
"error": DARK_STATE_MSG_ERROR,
"info": DARK_STATE_MSG_INFO,
}
return {
"available": STATE_AVAILABLE,
"unavailable": STATE_UNAVAILABLE,
"error": STATE_MSG_ERROR,
"info": STATE_MSG_INFO,
}
# -- Sample tables + raster grid --------------------------------------------
SAMPLE_ROW_ACTIVE_BG = "#ff6600"
SAMPLE_ROW_QUEUED_BG = "#729fcf" # mounted/current-sample row (legacy name)
SAMPLE_ROW_HIGHLIGHT_BG = "#d8e4fd"
RASTER_GRID_LINE = "#729fcf"
TABLE_SHADE_BG = "#e0e0e0"
# -- Sample status row tints (dewar/queue view) -----------------------------
# Status colors now fill the rounded dot in the table's status column; rows
# themselves alternate WHITE / SAMPLE_ROW_ALT_BG. Tune the palette here.
SAMPLE_ROW_ALT_BG = "#eef1f5" # staggered row grey (alternates with white)
SAMPLE_STATUS_QUEUED_BG = "#ffe4c4" # pale orange — waiting in the automation queue
SAMPLE_STATUS_FLAGGED_BG = "#ffd9d9" # pale red — automation failed on this sample
SAMPLE_STATUS_MEASURED_BG = "#dcf2e0" # pale green — already has collected data
SAMPLE_STATUS_SELECTED_BG = "#84abd9" # pale blue — table selection highlight
# Fixed ink on the pastel tints above: the tints stay light in BOTH themes,
# so theme-following text (white in Sunset) would vanish on them. Models
# return this as ForegroundRole wherever they return a tint.
SAMPLE_STATUS_TEXT = "#263043"
# -- Camera / video overlay (painter colors, alpha at call site) ------------
# Palette: Catppuccin Latte (light flavor) — softer than the old pure-RGB set.
BEAM_OPEN = "#40a02b" # beam marker: shutter open (green)
BEAM_IDLE = "#fe640b" # beam marker: idle (peach)
BEAM_BUSY = "#d20f39" # beam marker: busy (red)
BEAM_MARKING = "#8839ef" # beam marker: marking mode (mauve)
MARKER_GREEN = "#40a02b" # loop-centering click marker (green)
PATH_START = "#40a02b" # raster path gradient start + start circle (green)
PATH_END = "#d20f39" # raster path gradient end + end circle (red)
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
# between chart and overlay collapses to the single Latte green.
CLASS_COLORS = {
"pin": "#d20f39", # red
"loop_all": "#40a02b", # green
"loop_face": "#df8e1d", # yellow
"crystal": "#1e66f5", # blue
"needle": "#ea76cb", # pink
"ice": "#04a5e5", # sky
}
CHART_CLASS_COLORS = {
"Pin": "#d20f39",
"Loop_all": "#40a02b",
"Loop_face": "#df8e1d",
"Crystal": "#1e66f5",
"Needle": "#ea76cb",
"Ice": "#04a5e5",
}
TARGET_COLORS = {"Cyan": "#04a5e5", "Dark Blue": "#1e66f5", "Dark Red": "#e64553"}
BOOKMARK_COLORS = {
"red": "#d20f39",
"green": "#40a02b",
"blue": "#1e66f5",
"indigo": "#8839ef", # mauve
"lime": "#179299", # teal — Latte has one green; teal keeps the pair distinct
}
# -- 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"
BUSY_PSI_RED = "#e64553" # maroon — closest Latte to the PSI brand red
BUSY_PSI_RED_BORDER = "#edc6cc"
BUSY_PSI_RED_DOT = "#edcfd5"
# -- Charts (prediction metrics, target stability, fluorescence) ------------
# Catppuccin Latte; PALE variants are 35% mixes toward Latte base.
CHART_BLUE = "#1e66f5" # blue
CHART_BLUE_LIGHT = "#04a5e5" # sky
CHART_BLUE_PALE = "#a6c0f5"
CHART_RED = "#d20f39" # red
CHART_RED_LIGHT = "#dd7878" # flamingo
CHART_RED_DARK = "#e64553" # maroon
CHART_ORANGE = "#fe640b" # peach
CHART_ORANGE_PALE = "#f4c0a3"
CHART_GREEN = "#40a02b" # green
CHART_GREEN_PALE = "#b2d5ae"
CHART_CYAN = "#179299" # teal
CHART_PURPLE = "#8839ef" # mauve
CHART_MUTED = "#8c8fa1" # overlay1
CONFIDENCE_BIN_COLORS = [CHART_RED, CHART_ORANGE, CHART_ORANGE_PALE, CHART_GREEN_PALE, CHART_GREEN]
SPECTRUM_LINE = "#d20f39" # red
# -- Generic panels (developer help, raster table) --------------------------
PANEL_BG_SOFT = "#f6f6f6"
PANEL_BG_FAINT = "#fafafa"
PANEL_BORDER = "#d0d0d0"
PANEL_BORDER_DARK = "#bdbdbd"
PANEL_BORDER_LIGHT = "#e0e0e0"
TUTORIAL_BORDER = "#555555"
TUTORIAL_HIGHLIGHT = "#ffff00" # widget spotlight pen (was Qt.yellow)
# -- Typography ladder (role-named; tune sizes here, not in widgets) --------
FONT_ALERT = "18px" # alert banner text
FONT_VALUE = "18px" # prominent values, big glyph buttons
FONT_TITLE = "16px" # section / panel titles
FONT_BODY_LG = "14px" # primary buttons, emphasized body text — always bold
FONT_BODY = "14px" # default UI text
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.
# Discrete ladder: 100% / 125% / 150% only. Free ±10% steps produced
# in-between sizes where the fixed-width side boxes clipped their text;
# three vetted stops keep fonts and box widths in lockstep.
FONT_SCALE_LADDER = (1.0, 1.25, 1.5)
_font_scale = 1.0
def font_scale() -> float:
return _font_scale
def set_font_scale(scale: float) -> None:
# Snap to the ladder so old saved settings (e.g. 1.2 from the former
# free-step zoom) land on the nearest vetted stop.
global _font_scale
_font_scale = min(FONT_SCALE_LADDER, key=lambda s: abs(s - scale))
# -- Hover tooltips (the QToolTip popup; styled borderless) -----------------
TOOLTIP_BG = "#f7f9fc"
TOOLTIP_FG = "#263043"
DARK_TOOLTIP_BG = "#0e1728" # dusk panel2 — deepest opaque (menus/tooltips)
DARK_TOOLTIP_FG = "#e9edf4" # dusk text
def admin_tip_qss(theme: str) -> str:
"""QToolTip rule for admin-only warning tips. Set it on the widget the
tip is shown for, never app-wide — QToolTip inherits QSS from that
widget, and an app-wide rule would turn every tooltip red."""
bg, fg = (
(DARK_TOOLTIP_BG, DARK_TOOLTIP_FG) if theme == THEME_SUNSET else (TOOLTIP_BG, TOOLTIP_FG)
)
# Transparent border required, same as the app-wide QToolTip rule.
return (
f"QToolTip {{ background-color: {bg}; color: {fg};"
f" border: 1px solid transparent; padding: 4px 6px; }}"
)
# -- Sliders ----------------------------------------------------------------
# Own knob instead of PRIMARY: full-saturation button blue was too loud for a
# passive fill (illumination panel). Muted slate-blue, tweak freely.
SLIDER_FILL = "#8ba3c7"
# -- Scrollbars (rounded, no arrows) ----------------------------------------
# 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 = "#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.
DISABLED_INPUT_BG = "#d8dde5"
SLIDER_TRACK = "#d8dde5" # groove
SLIDER_MUTED = "#a8b2c0" # handle border + disabled fill
# -- Cards ------------------------------------------------------------------
CARD_RADIUS = "12px" # one radius for every card-shaped frame
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.
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):
"""QColor from a palette hex string, optional 0-255 alpha.
Hue lives here (theme), alpha stays at the call site (behavior). Lazy Qt
import so this module — and its self-check — stay importable without
PySide6.
"""
from PySide6.QtGui import QColor
c = QColor(color)
if alpha is not None:
c.setAlpha(alpha)
return c
def card_style(
bg: str,
border: str,
text: str | None = None,
*,
selector: str = "QFrame",
border_px: int = 1,
radius: str = CARD_RADIUS,
extra: str = "",
) -> str:
"""One QSS rule for every card-shaped frame: geometry stays uniform
(CARD_RADIUS unless overridden), only the role colors vary. `extra`
appends declarations (padding, font-weight, …) inside the same rule."""
color = f" color: {text};" if text else ""
tail = f" {extra}" if extra else ""
return (
f"{selector} {{ background: {bg};{color}"
f" border: {border_px}px solid {border}; border-radius: {radius};{tail} }}"
)
def build_app_stylesheet(theme: str) -> str:
if theme == THEME_SUNSET:
return _sunset_stylesheet()
if theme == THEME_BLUEBIRD:
# Same sheet as Sunrise, sky flattened to the solid top color.
return _sunrise_stylesheet({"app_background": BACKGROUND_GRADIENT_MID})
return _sunrise_stylesheet()
def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str:
mapping = _palette()
if overrides:
mapping.update(overrides)
return Template("""
/* Sunrise-sky gradient: only top-level windows paint it (rule order
matters — this must come AFTER the transparent QWidget rule so it
wins the specificity tie). Knobs: BACKGROUND_GRADIENT_* above. */
QWidget {
background-color: transparent;
color: $text;
}
QMainWindow, QDialog, PopoutWindow,
QDockWidget[floating="true"] {
background: $app_background;
}
/* These used to take the flat fill from the global QWidget rule; now
that it is transparent they need an opaque face of their own
(buttons, inputs, headers) or an opaque popup canvas (menus,
combo dropdown lists). */
QHeaderView::section, QMenu,
QComboBox QAbstractItemView {
background-color: $background;
}
/* Text views (console log): transparent, SAME as the dark theme — keep
the two sheets' transparency decisions in lockstep. */
QTextEdit, QPlainTextEdit {
background-color: transparent;
}
/* Flat compact buttons, mirroring the dark theme's elevated+hairline
look — the explicit border drops the padded native chrome.
min/max-height + vertical padding pinned to the SAME values as
the inputs below: without them each widget derives its own height
from sizeHint and buttons end up taller than the entry boxes.
CAUTION: this cap hits EVERY QPushButton/QToolButton/QComboBox.
Icon buttons with a fixed size (dock title-bar popout/close) or
multi-line buttons (beamline state strip) get squashed: the padding
shrinks their content box and Qt scales the icon/text down. Any such
widget must opt out with a more specific rule or its own widget-level
stylesheet setting padding: 0 / min-height: 0 / its real max-height —
see popout_window._titlebar_button and the beamlineStatePanel rule
below for the two existing patterns. */
QPushButton, QToolButton, QComboBox {
background-color: $button_bg;
border: 1px solid $button_border;
min-height: 16px;
max-height: 16px;
padding: 1px 8px;
}
/* Hover darkens in the light theme (ink tint over the sky); the dark
theme lightens instead — direction always moves toward contrast. */
QPushButton:hover, QToolButton:hover, QComboBox:hover {
background-color: $button_bg_hover;
}
/* Inputs — centralized (was per-widget INPUT_BG stylesheets, which
pinned light fills into the dark theme). Read-only is the QSS
pseudo-class; "invalid" is a dynamic property set by NumberLineEdit.
The explicit border drops the tall native input chrome — same
compact QSS box rendering the dark theme gets. */
QLineEdit, QAbstractSpinBox {
background-color: $input_bg;
border: 1px solid $button_border;
min-height: 16px;
max-height: 16px;
padding: 1px 6px;
}
/* Beamline state strip: exempt from the global control-height cap.
Its entries are QPushButtons that wrap to two lines when the window
is narrow (see BeamlineStatePanel._update_label_mode) — the 16px cap
would clip the second line. */
QFrame#beamlineStatePanel QPushButton {
min-height: 0px;
max-height: 64px;
}
QLineEdit:read-only, QAbstractSpinBox:read-only {
background-color: $input_disabled_bg;
}
/* Motor batch-move states (before the invalid rules on purpose: equal
specificity, so out-of-range red must come last to win). Spinboxes
(Set Energy) carry the property on the box itself — their inner
QLineEdit is transparent by design (rule below), so it can't show
the state. */
QLineEdit[movestate="pending"], QAbstractSpinBox[movestate="pending"] {
background-color: $input_pending_bg;
}
QLineEdit[movestate="moving"], QAbstractSpinBox[movestate="moving"] {
background-color: $input_moving_bg;
}
QLineEdit[invalid="true"] {
background-color: $input_invalid_bg;
}
QLineEdit[invalid="true"]:read-only {
background-color: $input_disabled_invalid_bg;
}
/* Spinboxes: both bare arrows adjacent on the right — up inboard, down
at the outer edge (wide fields put opposite-side arrows miles apart).
No button face or frame, the box's own glass is the whole control;
the value region spans everything left of the pair. Arrows are PNG
assets (SPIN_ARROW_*) — this Qt draws neither native glyphs nor
border-triangles inside styled buttons. */
QAbstractSpinBox {
padding-left: 6px;
padding-right: 34px;
}
QAbstractSpinBox::down-button {
subcontrol-origin: border;
subcontrol-position: center right;
width: 16px;
background: transparent;
border: none;
}
QAbstractSpinBox::up-button {
subcontrol-origin: border;
subcontrol-position: center right;
left: -16px;
width: 16px;
background: transparent;
border: none;
}
QAbstractSpinBox::down-arrow {
image: url($spin_arrow_down);
}
QAbstractSpinBox::up-arrow {
image: url($spin_arrow_up);
}
/* Bare dropdown: no framed native button around the combo arrow. */
QComboBox::drop-down {
border: none;
background: transparent;
}
QComboBox::down-arrow {
image: url($spin_arrow_down);
}
/* The value area is a QLineEdit INSIDE the spinbox — left alone it
stacks its own INPUT_BG glass on the spinbox's, reading near-solid
white. The box already signals "editable"; one glass layer is enough. */
QAbstractSpinBox QLineEdit {
background: transparent;
}
/* Table select-all corner: QHeaderView::section above doesn't match it,
and unpainted it renders black on the container's X11. */
QTableCornerButton::section {
background-color: $background;
border: none;
}
QWidget#mainContentRoot,
QWidget#standardMainPage {
background-color: transparent;
}
/* Borderless hover hints. The transparent border is required — QToolTip
only honours the stylesheet background once a border is set. */
QToolTip {
background-color: $tooltip_bg;
color: $tooltip_fg;
border: 1px solid transparent;
padding: 4px 6px;
}
QFrame#alertBanner[alertKind="error"] {
background-color: $error_bg;
border: 2px solid $error_border;
border-radius: $card_radius;
margin: 8px 12px 8px 12px;
}
QFrame#alertBanner[alertKind="success"] {
background-color: $success_bg;
border: 2px solid $success_border;
border-radius: $card_radius;
margin: 8px 12px 8px 12px;
}
QFrame#alertBanner[alertKind="waiting"] {
background-color: $warning_bg;
border: 2px solid $warning_border;
border-radius: $card_radius;
margin: 8px 12px 8px 12px;
}
QFrame#alertBanner QLabel {
font-weight: 700;
font-size: $font_alert;
padding: 2px 6px 2px 6px;
}
QFrame#alertBanner[alertKind="error"] QLabel {
color: $error_text;
}
QFrame#alertBanner[alertKind="success"] QLabel {
color: $success_text;
}
QFrame#alertBanner[alertKind="waiting"] QLabel {
color: $warning_text;
}
/* Check/radio indicators: explicit QSS boxes, both SQUARE for
consistency — hollow = unchecked, accent-filled = checked. Native
indicator glyphs are unreliable on this Qt once anything nearby is
styled (same story as the spin arrows). */
QCheckBox::indicator, QRadioButton::indicator {
width: 12px;
height: 12px;
background: $input_bg;
border: 1px solid $scrollbar_track;
}
QCheckBox::indicator:checked, QRadioButton::indicator:checked {
image: url($check_mark);
}
/* Radios stay ROUND — single-choice groups must read as radios, not
checkboxes. Checked = accent dot (a check mark in a circle reads
as a squashed checkbox). */
QRadioButton::indicator {
border-radius: 6px;
}
QRadioButton::indicator:checked {
image: none;
background: $primary;
}
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
background: $disabled_input_bg;
}
/* Disabled = baton-gated ("watch only"): the explicit colors above mask
Qt's native grey, so spell the greyed state out. */
QPushButton:disabled, QCheckBox:disabled, QRadioButton:disabled,
QLabel:disabled, QComboBox:disabled, QLineEdit:disabled,
QAbstractSpinBox:disabled, QTabBar::tab:disabled {
color: $faint_text;
}
QLineEdit:disabled, QAbstractSpinBox:disabled, QComboBox:disabled {
background: $disabled_input_bg;
}
QSlider::sub-page:horizontal:disabled {
background: $slider_muted;
}
QTabWidget::pane {
border: 1px solid $border;
}
/* Exp. Config. pane: hand-rolled QTabBar+QStackedWidget (button row sits
between bar and pages), so it needs the pane border by hand too. */
#expConfigPane {
border: 1px solid $border;
}
/* Borderless tabs: the pane isn't outlined, so the native 3-side tab
boxes hung in the air. Selection shows via fill + the raised effect
(unselected tabs sit 3px lower). */
QTabBar::tab {
background: transparent;
border: none;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 4px 14px;
margin-top: 3px;
/* 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 {
background: $tab_face_bg;
color: $text;
margin-top: 0px;
padding: 6px 14px 5px 14px;
}
/* Menu-bar-style hover hint: text-decoration underlines only the text
itself (short, close to the baseline) instead of the full tab box. */
QTabBar::tab:hover:!selected {
color: $text;
text-decoration: underline;
}
/* Sample-list status filter chips — tab-shaped buttons under the Dewar/
Auxiliary tabs (objectName filterChip, set in tell_sample_panel).
Shell mirrors QTabBar::tab above; the checked chip wears its row-tint
color, doubling as the legend. Per-theme HERE, not inline, so the
dark theme can restyle them. */
QPushButton#filterChip {
background: $tab_face_bg;
color: $muted_text;
border: none;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
margin-top: 3px;
padding: 4px 14px;
}
QPushButton#filterChip:hover:!checked {
color: $text;
text-decoration: underline;
}
QPushButton#filterChip:checked {
background: $chip_neutral_bg;
color: $text;
margin-top: 0px;
padding: 6px 14px 5px 14px;
}
QPushButton#filterChip[status_key="queued"]:checked { background: $sample_status_queued_bg; }
QPushButton#filterChip[status_key="flagged"]:checked { background: $sample_status_flagged_bg; }
QPushButton#filterChip[status_key="measured"]:checked { background: $sample_status_measured_bg; }
/* In-panel section headings (section_title in title_label.py). */
QLabel#sectionTitle {
background: transparent;
color: $muted_text;
font-size: $font_hint;
font-weight: 700;
}
/* Resize gutters: the mouse keeps the full $separator_region, but only
the short centered GRIP_* bar paints (image: never stretches, it
centers). An upright grip is the _v file: 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;
}
QSplitter::handle { background: transparent; }
QMainWindow::separator:vertical, QSplitter::handle:horizontal {
image: url($grip_idle_v);
}
QMainWindow::separator:horizontal, QSplitter::handle:vertical {
image: url($grip_idle_h);
}
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:vertical:hover {
image: none;
background: $sep_hint_x;
}
QMainWindow[separatorHint="true"]::separator:horizontal:hover {
image: none;
background: $sep_hint_y;
}
/* Splitter handles (camera region, prediction metrics) are plain child
widgets the property gate above doesn't reach — immediate hover/press
hint. */
QSplitter::handle:horizontal:hover, QSplitter::handle:horizontal:pressed {
image: none;
background: $sep_hint_x;
}
QSplitter::handle:vertical:hover, QSplitter::handle:vertical:pressed {
image: none;
background: $sep_hint_y;
}
QFrame#beamlineControls,
QFrame#dataCollectionSettings {
border: 1px solid $border;
}
QFrame#beamlineStatePanel {
background: transparent;
border-top: 1px solid $border;
}
QLabel#beamlineStateTitle {
background-color: $banner;
color: $banner_text;
font-size: $font_title;
font-weight: 700;
border-left: 1px solid $banner_edge_v;
border-bottom: 1px solid $banner_edge_h;
}
/* Panel banners — styled per-theme HERE, not on the widget (a widget
stylesheet would win and pin this light banner into the dark theme).
TitleLabel hand-paints its text from the QSS-resolved palette color. */
TitleLabel {
background-color: $banner;
color: $banner_text;
font-size: $font_title;
font-weight: 700;
border-left: 1px solid $banner_edge_v;
border-bottom: 1px solid $banner_edge_h;
}
/* Bare glyph to match the TitleLabel toggles: no pill background. */
QPushButton#beamlineStateToggleButton {
border: none;
background: transparent;
color: $state_toggle_text;
font-size: $font_body;
font-weight: 700;
}
QLabel#beamlineStateCurrentLabel {
color: $state_current_text;
font-size: $font_value;
font-weight: 700;
padding-left: 4px;
background: transparent;
}
QLabel#beamlineStateTellLabel {
color: $state_tell_text;
font-size: $font_body_lg;
font-weight: 700;
padding-left: 4px;
background: transparent;
}
/* Plain scroll containers stay frameless. */
QScrollArea {
border: none;
}
/* Box-frame levels — weights/colors are knobs in styles.py. L2: panel
frames (replaces their native StyledPanel etching); L3: data views. */
TellSamplePanel, ReferenceToolsPanel, SampleQueuePanel {
border: $frame_l2_width solid $frame_l2_color;
}
/* No L3 border — matches the dark theme (borderless data views). */
QTableView, QPlainTextEdit {
border: none;
}
/* Pale-blue selection with readable dark text in every sample table;
staggered grey/white rows where alternation is enabled. */
QTableView {
background: $white;
alternate-background-color: $sample_row_alt_bg;
selection-background-color: $sample_status_selected_bg;
selection-color: $text;
}
/* Selection highlight: the banner blue (SELECTION_BG in styles.py)
instead of the style palette's own blue. Tables keep their paler
selection rule below. */
QListView, QTreeView,
QComboBox QAbstractItemView {
selection-background-color: $selection_bg;
selection-color: $selection_text;
}
QMenu::item:selected {
background: $selection_bg;
color: $selection_text;
}
QLineEdit, QAbstractSpinBox, QTextEdit, QPlainTextEdit {
selection-background-color: $selection_bg;
selection-color: $selection_text;
}
/* Sliders: rounded groove ends (like the scrollbar handles) + square
handle with grip lines (SLIDER_GRIP asset) — the old 16px round handle
was clipped flat by the slider's widget height. Wheel adjustment needs
the right mouse button held — see WheelValueGuard. */
QSlider::groove:horizontal {
height: 6px;
background: $slider_track;
border-radius: 3px;
}
QSlider::sub-page:horizontal {
background: $slider_fill;
border-radius: 3px;
}
QSlider::handle:horizontal {
background: $white;
border: 1px solid $slider_muted;
width: 14px;
margin: -3px 0;
image: url($slider_grip);
}
/* Soft scrollbars: square track band (runs flush to the widget edges —
rounded ends left corner notches in the tables) + rounded draggable
handle, no end arrows. */
QScrollBar:vertical {
background: $scrollbar_track;
width: 10px;
border: none;
margin: 0px;
}
QScrollBar:horizontal {
background: $scrollbar_track;
height: 10px;
border: none;
margin: 0px;
}
QScrollBar::handle:vertical {
background: $scrollbar_handle;
border-radius: 5px;
min-height: 24px;
}
QScrollBar::handle:horizontal {
background: $scrollbar_handle;
border-radius: 5px;
min-width: 24px;
}
QScrollBar::handle:vertical:hover,
QScrollBar::handle:vertical:pressed,
QScrollBar::handle:horizontal:hover,
QScrollBar::handle:horizontal:pressed {
background: $scrollbar_handle_hover;
}
QScrollBar::add-line, QScrollBar::sub-line {
width: 0px;
height: 0px;
}
QScrollBar::add-page, QScrollBar::sub-page {
background: transparent;
}
/* The two scrollbar bands meet in a track-colored corner — no white
square, the bands read as one continuous edge. */
QAbstractScrollArea::corner {
background: $scrollbar_track;
border: none;
}
""").substitute(mapping)
def _sunset_stylesheet() -> str:
return Template("""
/* Sunset-sky gradient — same transparent-children scheme as the light
theme: only top-level windows paint the sky (rule order matters, see
the light-theme note). Knobs: DARK_BACKGROUND_GRADIENT_* above. */
QWidget {
background-color: transparent;
color: $dark_text;
}
QMainWindow, QDialog, PopoutWindow,
QDockWidget[floating="true"] {
background: $dark_app_background;
}
/* Interactive faces sit one step above the backdrop (site: glass2)
with the faint gold hairline. Same pinned height as the light sheet
so buttons and entry boxes match in both themes. */
QPushButton, QToolButton, QComboBox {
background-color: $dark_elevated;
border: 1px solid $dark_border_faint;
min-height: 16px;
max-height: 24px;
padding-top: 1px;
padding-bottom: 1px;
}
/* Inputs: half the button glass (DARK_INPUT_BG) — a shared fill made
entry boxes indistinguishable from clickable buttons. Same box
metrics as above so the two still line up in rows. */
QLineEdit, QAbstractSpinBox {
background-color: $dark_input_bg;
border: 1px solid $dark_border_faint;
min-height: 16px;
max-height: 16px;
padding-top: 1px;
padding-bottom: 1px;
}
/* Hover lightens in the dark theme (one glass step up); the light
theme darkens instead — direction always moves toward contrast. */
QPushButton:hover, QToolButton:hover, QComboBox:hover {
background-color: $dark_elevated_hover;
}
/* Beamline state strip: exempt from the height cap — two-line labels
(see the light sheet's matching rule). */
QFrame#beamlineStatePanel QPushButton {
min-height: 0px;
max-height: 64px;
}
/* Text views (console log) read fine straight on the sky. */
QTextEdit, QPlainTextEdit {
background-color: transparent;
}
/* Menus/popups use the deepest opaque surface (site: panel2). */
QMenu,
QComboBox QAbstractItemView {
background-color: $dark_panel2;
}
QHeaderView::section {
background-color: $dark_surface;
color: $dark_text;
border: none;
}
/* Check/radio indicators, dark flavor — square boxes, gold = checked
(see the light-theme note). */
QCheckBox::indicator, QRadioButton::indicator {
width: 12px;
height: 12px;
background: transparent;
border: 1px solid $dark_muted;
}
QCheckBox::indicator:checked, QRadioButton::indicator:checked {
image: url($dark_check_mark);
}
/* Radios stay ROUND (see the light-theme note). Checked = gold dot. */
QRadioButton::indicator {
border-radius: 7px;
}
QRadioButton::indicator:checked {
image: none;
background: $dark_accent;
}
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
background: $dark_disabled;
}
/* Disabled = baton-gated: overlay text on the disabled fill. */
QPushButton:disabled, QCheckBox:disabled, QRadioButton:disabled,
QLabel:disabled, QComboBox:disabled, QLineEdit:disabled,
QAbstractSpinBox:disabled, QTabBar::tab:disabled {
color: $dark_overlay;
}
QLineEdit:disabled, QAbstractSpinBox:disabled, QComboBox:disabled {
background: $dark_disabled;
}
/* Input states, dark flavor — see the light-theme note. */
QLineEdit:read-only, QAbstractSpinBox:read-only {
background-color: $dark_disabled;
}
QLineEdit[movestate="pending"], QAbstractSpinBox[movestate="pending"] {
background-color: $dark_input_pending_bg;
}
QLineEdit[movestate="moving"], QAbstractSpinBox[movestate="moving"] {
background-color: $dark_input_moving_bg;
}
QLineEdit[invalid="true"] {
background-color: $dark_error_bg;
}
/* Spinboxes: both bare arrows adjacent on the right (up inboard, down
outermost) — see the light-theme note. Arrows are the
DARK_SPIN_ARROW_* PNG assets. */
QAbstractSpinBox {
padding-left: 6px;
padding-right: 34px;
}
QAbstractSpinBox::down-button {
subcontrol-origin: border;
subcontrol-position: center right;
width: 16px;
background: transparent;
border: none;
}
QAbstractSpinBox::up-button {
subcontrol-origin: border;
subcontrol-position: center right;
left: -16px;
width: 16px;
background: transparent;
border: none;
}
QAbstractSpinBox::down-arrow {
image: url($dark_spin_arrow_down);
}
QAbstractSpinBox::up-arrow {
image: url($dark_spin_arrow_up);
}
/* Bare dropdown — see the light-theme note. */
QComboBox::drop-down {
border: none;
background: transparent;
}
QComboBox::down-arrow {
image: url($dark_spin_arrow_down);
}
/* Single glass layer for the embedded value edit — see light-theme note. */
QAbstractSpinBox QLineEdit {
background: transparent;
}
/* Filter chips, dark flavor: same shell as the Dewar/Auxiliary tabs,
but the checked chip keeps its light row-tint fill (the legend role)
with the fixed dark ink the tints require. */
QPushButton#filterChip {
background: transparent;
color: $dark_muted;
border: none;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
margin-top: 3px;
padding: 4px 14px;
}
QPushButton#filterChip:hover:!checked {
color: $dark_text;
text-decoration: underline;
}
QPushButton#filterChip:checked {
background: $chip_neutral_bg;
color: $sample_status_text;
margin-top: 0px;
padding: 6px 14px 5px 14px;
}
QPushButton#filterChip[status_key="queued"]:checked { background: $sample_status_queued_bg; }
QPushButton#filterChip[status_key="flagged"]:checked { background: $sample_status_flagged_bg; }
QPushButton#filterChip[status_key="measured"]:checked { background: $sample_status_measured_bg; }
/* In-panel section headings — gold. */
QLabel#sectionTitle {
background: transparent;
color: $dark_accent;
font-size: $font_hint;
font-weight: 700;
}
/* Panel banners — styled per-theme HERE, not on the widget (a widget
stylesheet would win and pin the light banner into this theme). Gold
identity text, hand-painted by TitleLabel from the QSS palette. */
TitleLabel {
background-color: $dark_elevated;
color: $dark_accent;
font-size: $font_title;
font-weight: 700;
border-left: 1px solid $dark_banner_edge_v;
border-bottom: 1px solid $dark_banner_edge_h;
}
QTabWidget::pane,
QScrollArea,
QDockWidget,
QDockWidget > QWidget {
background: transparent;
color: $dark_text;
}
/* Borderless tabs, dark flavor — see the light-theme note. */
QTabBar::tab {
background: transparent;
border: none;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 4px 14px;
margin-top: 3px;
color: $dark_muted;
}
QTabBar::tab:selected {
background: $dark_elevated;
color: $dark_text;
margin-top: 0px;
padding: 6px 14px 5px 14px;
}
/* Menu-bar-style hover hint — see the light-theme note. */
QTabBar::tab:hover:!selected {
color: $dark_text;
text-decoration: underline;
}
/* Borderless hover hints. The transparent border is required — QToolTip
only honours the stylesheet background once a border is set. */
QToolTip {
background-color: $dark_tooltip_bg;
color: $dark_tooltip_fg;
border: 1px solid transparent;
padding: 4px 6px;
}
QFrame#alertBanner[alertKind="error"] {
background: $dark_error_bg;
border: 2px solid $dark_error_border;
border-radius: $card_radius;
margin: 8px 12px 8px 12px;
}
QFrame#alertBanner[alertKind="success"] {
background: $dark_success_bg;
border: 2px solid $dark_success_border;
border-radius: $card_radius;
margin: 8px 12px 8px 12px;
}
QFrame#alertBanner[alertKind="waiting"] {
background: $dark_warning_bg;
border: 2px solid $dark_warning_border;
border-radius: $card_radius;
margin: 8px 12px 8px 12px;
}
QFrame#alertBanner QLabel {
font-weight: 700;
font-size: $font_alert;
padding: 2px 6px 2px 6px;
}
QFrame#alertBanner[alertKind="error"] QLabel {
color: $dark_error_text;
}
QFrame#alertBanner[alertKind="success"] QLabel {
color: $dark_success_text;
}
QFrame#alertBanner[alertKind="waiting"] QLabel {
color: $dark_warning_text;
}
QFrame#beamlineStatePanel {
background: transparent;
border-top: 1px solid transparent;
}
QLabel#beamlineStateTitle {
background-color: $dark_elevated;
color: $dark_accent;
font-size: $font_title;
font-weight: 700;
border-left: 1px solid $dark_banner_edge_v;
border-bottom: 1px solid $dark_banner_edge_h;
}
/* Bare glyph to match the TitleLabel toggles: no pill background. */
QPushButton#beamlineStateToggleButton {
border: none;
background: transparent;
color: $dark_text;
font-size: $font_body;
font-weight: 700;
}
QLabel#beamlineStateCurrentLabel {
color: $dark_text;
font-size: $font_value;
font-weight: 700;
padding-left: 4px;
background: transparent;
}
QLabel#beamlineStateTellLabel {
/* Same color as the Current-state neighbor — subtext was unreadable
on the selected-state blue band. */
color: $dark_text;
font-size: $font_body_lg;
font-weight: 700;
padding-left: 4px;
background: transparent;
}
/* Idle + hover resize grips, dark flavor — see the light-theme note. */
QMainWindow::separator {
width: $separator_region;
height: $separator_region;
background: transparent;
}
QSplitter::handle { background: transparent; }
QMainWindow::separator:vertical, QSplitter::handle:horizontal {
image: url($dark_grip_idle_v);
}
QMainWindow::separator:horizontal, QSplitter::handle:vertical {
image: url($dark_grip_idle_h);
}
QSplitter::handle:horizontal { width: $separator_region; }
QSplitter::handle:vertical { height: $separator_region; }
QMainWindow[separatorHint="true"]::separator:vertical:hover {
image: none;
background: $dark_sep_hint_x;
}
QMainWindow[separatorHint="true"]::separator:horizontal:hover {
image: none;
background: $dark_sep_hint_y;
}
QSplitter::handle:horizontal:hover, QSplitter::handle:horizontal:pressed {
image: none;
background: $dark_sep_hint_x;
}
QSplitter::handle:vertical:hover, QSplitter::handle:vertical:pressed {
image: none;
background: $dark_sep_hint_y;
}
/* Plain scroll containers stay frameless. */
QScrollArea {
border: none;
}
/* Box-frame levels — weights/colors are knobs in styles.py. Opaque
fill on purpose: left transparent, the panel band (behind the filter
chips) renders BLACK on the container's non-composited X11 — same
trap as DARK_TABLE_BG. */
TellSamplePanel, ReferenceToolsPanel, SampleQueuePanel {
border: $frame_l2_width solid $frame_l2_color;
background: $dark_surface;
}
/* Dewar tab page: the automation button row sits on this bare QWidget
below the panel's border — left transparent it shows the near-black
gradient bottom, reading as an unpainted hole. */
QWidget#dewarTab {
background-color: $dark_surface;
}
QWidget#logPanel { background-color: $dark_surface; }
/* No L3 border here: the light theme's pale hairline read as a white
frame around dark tables. */
QTableView, QPlainTextEdit {
border: none;
}
QPlainTextEdit {
background: $dark_table_bg; /* solid — transparent renders black on the container's X11 */
}
/* Selection + staggered rows, dark flavor. Solid fills on purpose —
a transparent viewport renders black here (see DARK_TABLE_BG). */
QTableView {
background: $dark_table_bg;
alternate-background-color: $dark_surface;
selection-background-color: $sample_status_selected_bg;
selection-color: $text;
}
/* Table select-all corner — see the light-theme note. */
QTableCornerButton::section {
background-color: $dark_surface;
border: none;
}
/* Selection highlight — same banner-blue knob as the light theme. */
QListView, QTreeView,
QComboBox QAbstractItemView {
selection-background-color: $selection_bg;
selection-color: $selection_text;
}
QMenu::item:selected {
background: $selection_bg;
color: $selection_text;
}
QLineEdit, QAbstractSpinBox, QTextEdit, QPlainTextEdit {
selection-background-color: $selection_bg;
selection-color: $selection_text;
}
/* Sliders, dark flavor — see the light-theme note. */
QSlider::groove:horizontal {
height: 6px;
background: $dark_border;
border-radius: 3px;
}
QSlider::sub-page:horizontal {
background: $dark_accent;
border-radius: 3px;
}
QSlider::handle:horizontal {
background: $dark_elevated;
border: 1px solid $dark_muted;
width: 14px;
margin: -3px 0;
image: url($dark_slider_grip);
}
/* Soft scrollbars: square track band + rounded handle, no end arrows
(see light-theme note on the corner notches). */
QScrollBar:vertical {
background: $dark_surface;
width: 10px;
border: none;
margin: 0px;
}
QScrollBar:horizontal {
background: $dark_surface;
height: 10px;
border: none;
margin: 0px;
}
QScrollBar::handle:vertical {
background: $dark_border;
border-radius: 5px;
min-height: 24px;
}
QScrollBar::handle:horizontal {
background: $dark_border;
border-radius: 5px;
min-width: 24px;
}
QScrollBar::handle:vertical:hover,
QScrollBar::handle:vertical:pressed,
QScrollBar::handle:horizontal:hover,
QScrollBar::handle:horizontal:pressed {
background: $dark_muted;
}
QScrollBar::add-line, QScrollBar::sub-line {
width: 0px;
height: 0px;
}
QScrollBar::add-page, QScrollBar::sub-page {
background: transparent;
}
/* The two scrollbar bands meet in a track-colored corner. */
QAbstractScrollArea::corner {
background: $dark_surface;
border: none;
}
""").substitute(_palette())
if __name__ == "__main__":
# ponytail: smallest check that fails if a $name has no matching constant
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) # snaps to the 1.25 ladder stop
assert "font-size: 20px" in build_app_stylesheet(THEME_SUNRISE) # title 16->20
set_font_scale(99.0)
assert font_scale() == FONT_SCALE_LADDER[-1]
set_font_scale(0.0)
assert font_scale() == FONT_SCALE_LADDER[0]
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")