"""Provide palettes, geometry tokens, and QSS for the spaCR Qt interface.
Use :func:`active_palette` for colors shown by a live widget and
:func:`stylesheet` for the application stylesheet. :data:`THEMES` contains
the selectable palettes ``"dark"``, ``"light"``, ``"cell"``, ``"glass"``,
``"high_contrast"`` and the night and data-art presets of
:mod:`spacr.qt.night_themes`; the ``"system"``
preference resolves to dark or light before palette lookup. A legacy
``"space"`` palette can still be read from persisted settings but is not
selectable.
.. warning::
``theme.PALETTE`` is a deprecated, read-only alias for the dark palette and
does not follow runtime theme changes. Use :func:`active_palette`, or
:data:`DARK_PALETTE` only when dark colors are explicitly required.
Cell and Glass are :data:`IMAGE_THEMES`. Their panels use translucent scrims
so the backdrop remains visible without sacrificing text contrast.
:func:`contrast_failures` validates roles painted on scrims, while
:func:`image_contrast_failures` validates roles painted directly over image
content. :func:`solve_scrim_alpha` balances those contrast constraints against
:data:`MIN_PICTURE_CONTRAST`, and :func:`scrim_report` exposes the result.
:func:`enable_spaceout` dresses the process in the rainbow palette the
``spaceout`` entry point launches into. It re-hues whichever theme is
resolved rather than adding a fifth one, so :data:`THEMES` and the light/dark
handling are untouched; it is process state and is never persisted.
"""
from __future__ import annotations
import logging
import math
import warnings
import weakref
from contextlib import contextmanager
from functools import lru_cache, partial
from types import MappingProxyType
from typing import Dict, List, Optional, Tuple
from PySide6.QtCore import QEvent, QObject, Qt, QTimer
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication
from .night_themes import DATA_ART_THEMES, DATA_ART_THEME_KEYS, NIGHT_THEME_KEYS
from .night_themes import palettes as night_palettes
LOG = logging.getLogger(__name__)
DARK_PALETTE = {
"bg": "#000000",
"page": "#23252a",
"surface": "#0d0e10",
"surface_alt": "#161719",
"surface_hi": "#1f2124",
"border": "#2a2d33",
"border_soft": "#1c1e22",
"fg": "#ffffff",
"fg_muted": "#a1a6ad",
"fg_dim": "#858a92",
"accent": "#4A9EFF",
"accent_hi": "#66B2FF",
"accent_lo": "#2F80D9",
"accent_soft": "#1e3550",
"success": "#3fb950",
"chip_class": "#1fb6ad",
"chip_value": "#3fb950",
"warning": "#d29922",
"error": "#f85149",
"info": "#4A9EFF",
}
LIGHT_PALETTE = {
"bg": "#fafafa",
"page": "#e1e4e6",
"surface": "#ffffff",
"surface_alt": "#f2f4f7",
"surface_hi": "#e6e9ee",
"border": "#d5d9df",
"border_soft": "#e5e8ec",
"fg": "#0d0e10",
"fg_muted": "#4b5460",
"fg_dim": "#596170",
"accent": "#0a63c4",
"accent_hi": "#0851a3",
"accent_lo": "#063d7a",
"accent_soft": "#dbe8fb",
"success": "#0f7030",
"chip_class": "#0a6f6a",
"chip_value": "#0f7030",
"warning": "#8f4e00",
"error": "#b81d1a",
"info": "#0a63c4",
}
SPACE_PALETTE = {
"bg": "#04060d",
"page": "#232b3d",
"surface": "#080d18",
"surface_alt": "#0d1524",
"surface_hi": "#182338",
"border": "#33405c",
"border_soft": "#1e293d",
"fg": "#ffffff",
"fg_muted": "#c2ccdd",
"fg_dim": "#93a0b6",
"accent": "#6cb6ff",
"accent_hi": "#9bcdff",
"accent_lo": "#3d8ddb",
"accent_soft": "#16304f",
"success": "#5fd97a",
"chip_class": "#4fd6ce",
"chip_value": "#5fd97a",
"warning": "#f0c14b",
"error": "#ff7b72",
"info": "#6cb6ff",
}
CELL_PALETTE = {
"bg": "#02080b",
"page": "#24363f",
"surface": "#061218",
"surface_alt": "#0b1c23",
"surface_hi": "#152d37",
"border": "#33596a",
"border_soft": "#1d3a46",
"fg": "#ffffff",
"fg_muted": "#cfe0e6",
"fg_dim": "#a3bbc4",
"accent": "#8fe3f7",
"accent_hi": "#b9f0ff",
"accent_lo": "#4fb3cf",
"accent_soft": "#123742",
"success": "#6fe39a",
"chip_class": "#5fe0d8",
"chip_value": "#6fe39a",
"warning": "#f2ca5c",
"error": "#ff8f86",
"info": "#8fe3f7",
}
GLASS_PALETTE = {
"bg": "#0b0d11",
"page": "#0b0d11",
"surface": "#25272c",
"surface_alt": "#2d3036",
"surface_hi": "#3a3e45",
"border": "#aeb2ba",
"border_soft": "#747982",
"fg": "#fafafa",
"fg_muted": "#d5d6d9",
"fg_dim": "#a8abb1",
"accent": "#8cc8ff",
"accent_hi": "#b8dcff",
"accent_lo": "#579fe0",
"accent_soft": "#263746",
"success": "#78dfa3",
"chip_class": "#6fe0d8",
"chip_value": "#78dfa3",
"warning": "#f5cf72",
"error": "#ff9a95",
"info": "#8cc8ff",
}
#: The high-contrast theme: pure white ink and outlines on black, with a
#: yellow accent, for low vision and bright rooms. Every text role clears
#: 7:1 on every surface it is painted on, which is the enhanced (AAA) level
#: rather than the 4.5:1 the other themes are held to.
HIGH_CONTRAST_PALETTE = {
"bg": "#000000",
"page": "#2b2b2b",
"surface": "#000000",
"surface_alt": "#0a0a0a",
"surface_hi": "#1a1a1a",
"border": "#ffffff",
"border_soft": "#bfbfbf",
"fg": "#ffffff",
"fg_muted": "#f0f0f0",
"fg_dim": "#e0e0e0",
"accent": "#ffd400",
"accent_hi": "#ffe866",
"accent_lo": "#e6bf00",
"accent_soft": "#3d3300",
"success": "#5cff8a",
"chip_class": "#33ffff",
"chip_value": "#5cff8a",
"warning": "#ffb84d",
"error": "#ff8080",
"info": "#80d4ff",
}
#: The themes with a palette of their own. ``"system"`` is a
#: *preference* value that resolves to one of these, not an entry here.
#: "space" was retired: the generated skies were a lot of machinery for a
#: backdrop nobody chose, and the Cell wallpapers do the same job with the
#: lab's own images. A persisted "space" falls back to dark — see
#: `preferences.get_theme`.
#:
#: The ten night themes of :mod:`spacr.qt.night_themes` are appended here,
#: which is what makes them ordinary themes: the contrast sweep, the page
#: separation sweep, the spaceout dressing and every widget that resolves a
#: colour through :func:`palette_for` reach them without a branch. They are
#: flat themes, not :data:`IMAGE_THEMES`, so no scrim is solved for them and
#: nothing is composited over a wallpaper.
THEMES = (("dark", "light", "cell", "glass", "high_contrast")
+ NIGHT_THEME_KEYS + DATA_ART_THEME_KEYS)
_PALETTES = {
"dark": DARK_PALETTE,
"light": LIGHT_PALETTE,
"space": SPACE_PALETTE,
"cell": CELL_PALETTE,
"glass": GLASS_PALETTE,
"high_contrast": HIGH_CONTRAST_PALETTE,
}
_PALETTES.update(night_palettes())
_PALETTES.update({key: dict(theme.palette) for key, theme in DATA_ART_THEMES.items()})
#: Themes whose window background is an image or depth gradient rather than
#: a flat colour. They share one treatment — a transparent ``QWidget``
#: default, translucent scrims on panels, and opaque popups — so the QSS
#: branches on membership here rather than on a theme name. The public name
#: is retained because Space and Cell predate the generated Glass backdrop.
IMAGE_THEMES = ("cell", "glass")
[docs]
def rim_colour(theme: str = "dark") -> str:
"""The meaningful hairline on outlined tiles — the theme's own ink.
White in the dark themes, near-black in the light one, because that
is what ``fg`` already is. Horizontal cards and interactive hover states
still use it; resting Home module tiles deliberately do not. Deriving it
from ``fg`` rather than writing ``#ffffff`` means every palette gets the
right ink without a raw colour drifting out of sync.
"""
return palette_for(theme)["fg"]
[docs]
def selection_ink(theme: str = "dark") -> str:
"""Text colour for a row selected with the accent behind it.
Derived, not chosen. `accent` is a mid blue on both themes, and the ink
that reads on it flips: measured, black is 7.63:1 on the dark accent and
white is 5.84:1 on the light one, while `button_accent_ink` -- the
obvious-looking role -- is 6.96:1 on dark and **3.28:1** on light, which
is below the 4.5 minimum and was shipped by picking a role instead of
measuring.
So it picks whichever of the theme's own two extremes contrasts better,
which keeps the colour inside the palette rather than reaching for a raw
#ffffff that belongs to no theme.
"""
palette = palette_for(theme)
accent = palette["accent"]
return max((palette["fg"], palette["bg"]),
key=lambda ink: contrast_ratio(ink, accent))
[docs]
def dock_colour(theme: str = "dark") -> str:
"""The left dock's background. **Never translucent, in any theme.**
The dock used to paint ``surface``, which the image themes re-render
through :func:`scrim_alpha` — so on Space and Cell the app list was a
ghost with a galaxy behind every row. A navigation column is chrome:
it is the thing you look at when you have lost your place, and it has
to be a solid edge for the page to end at.
White under the light theme (its ``surface``), a dark grey everywhere
else (``surface_alt``, one step up from the near-black window so the
dock reads as a separate plane rather than as more page).
"""
base = palette_for(theme)
return base["surface"] if theme == "light" else base["surface_alt"]
STAGE_HOVER = {
"stable": "#3B82F6",
"beta": "#FF00FF",
"alpha": "#00CEC8",
}
#: The mark an alpha settings category ends with, in place of "(Alpha)".
ALPHA_MARK = "\u03b1"
#: The one colour an alpha category's heading is written in, in every theme.
#:
#: Darker than the alpha hover hue so it still reads on the light theme's
#: white cards (3.7:1) while keeping at least 4:1 on every dark surface.
ALPHA_INK = "#00948F"
#: What the legend writes next to each swatch.
STAGE_LABEL = {
"stable": "Stable",
"beta": "Beta",
"alpha": "Alpha",
}
#: The legend's tooltip per stage — the sentence the colour stands in for.
STAGE_NOTE = {
"stable": "Signed off and in normal use.",
"beta": ("Further along than alpha and in regular use, but not "
"signed off yet."),
"alpha": ("Built and reachable, not yet trusted end to end. Expect "
"rough edges, and check the numbers before you rely on "
"them."),
}
[docs]
def stage_hover(stage: str) -> str:
"""Hover colour for ``stage``; unknown stages read as stable.
:param stage: the app stage, a key of ``STAGE_HOVER``.
"""
return STAGE_HOVER.get(stage, STAGE_HOVER["stable"])
TILE_W = 172
TILE_MAX_W = 260
TILE_H = 124
TILE_ICON_PX = 48
#: Worst-case pixel that a scrim can be composited over when nothing
#: bounds the wallpaper's brightness. A star core is pure white, so that
#: is what the contrast check assumes sits behind every panel —
#: anything dimmer only helps.
WORST_CASE_UNDER = "#ffffff"
#: How much of the wallpaper a scrim must still let through, as the WCAG
#: contrast ratio between a panel sitting over the brightest background
#: its theme can present and the same panel over black. It is the
#: dynamic range of the picture as seen *through* the panel.
#:
#: 1.5:1 is well above the ~1.1:1 at which a large-area luminance step
#: becomes visible at all — so the image is unambiguously present rather
#: than a ghost — and well below the 3:1 that WCAG 1.4.11 asks of a
#: meaningful UI boundary, so a card still reads as a card and not as a
#: hole. At the old 0.90 the same number was 1.10:1: right at the
#: threshold of visible, which is what the bug reports were about.
MIN_PICTURE_CONTRAST = 1.5
#: Multiplier applied to every WCAG minimum when solving an alpha, so a
#: solved scrim is not sitting exactly on the line. Qt composites
#: ``rgba()`` in 8-bit, and the solver's arithmetic is exact, so the
#: drift is a fraction of a level — but a rule that passes at 4.50:1 and
#: fails at 4.49:1 should not be the thing standing between the user and
#: a readable panel.
SCRIM_HEADROOM = 1.05
#: Themes whose wallpaper is *guaranteed* to stay under
#: :func:`max_background_luma` — every frame they can show has been
#: through :func:`spacr.qt.imagery.render`, which exposure-solves the
#: shipped masters and a user's own drop-in alike. Only these may have
#: their scrims judged against that ceiling instead of against white;
#: see :func:`scrim_under` for why Space is not one of them.
#:
#: A theme joins this set by having its wallpaper solved, not by being
#: added here. Adding one whose picture is not bounded would silently
#: thin its panels past what its own background can survive.
EXPOSURE_BOUNDED_THEMES = ("cell", "glass")
GLASS_BACKDROP_UNDER = "#454950"
def _grey_for_luminance(luminance: float) -> str:
"""The neutral grey whose WCAG relative luminance is ``luminance``.
A grey has equal linear channels, and the luminance weights sum to
1, so its relative luminance *is* its linear channel value — the
inverse is just the sRGB transfer function.
"""
value = max(0.0, min(1.0, float(luminance)))
srgb = (value * 12.92 if value <= 0.0031308
else 1.055 * value ** (1 / 2.4) - 0.055)
level = max(0, min(255, int(round(srgb * 255.0))))
return "#%02x%02x%02x" % (level, level, level)
[docs]
def scrim_under(theme: str) -> str:
"""Brightest colour ``theme``'s wallpaper can put behind a panel.
This is the whole reason the two image themes do not end up with the
same alphas, and it is a property of the *pipeline that produces the
wallpaper*, not of the palette:
* **Cell** wallpapers always come out of :func:`spacr.qt.imagery.render`,
which exposure-solves every frame it returns — the shipped masters
and the user's own drop-in alike. No text-line-sized region of a
Cell background can therefore exceed :func:`max_background_luma`,
and that ceiling, not white, is the worst case a Cell panel has to
survive. Measured: the shipped ``microtubules`` master peaks at
0.098 against a 0.109 limit, ``filopodia`` at 0.073.
* **Space** can be the procedurally generated sky, whose exposure is
anchored on the 40th percentile *precisely so a sun stays
white-hot* (:data:`spacr.qt.space.TARGET_SKY_PERCENTILE`). That is
a deliberate look, and it means the sky really does present a
near-white region the size of a line of text: the 1440x900 galaxy
sky measures 0.49 over a text window, colour ``#bab9b9``. Only a
strong scrim saves text over that, so Space is judged against
white and its panels stay much more opaque than Cell's.
Anything that is not an image theme gets white; its alphas are 1.0
and the answer is never used.
:param theme: the theme name.
"""
if theme == "glass":
return GLASS_BACKDROP_UNDER
if theme not in EXPOSURE_BOUNDED_THEMES:
return WORST_CASE_UNDER
return _grey_for_luminance(max_background_luma(theme))
def _scrim_rules(role: str) -> Tuple[Tuple[str, float], ...]:
"""``(foreground role, minimum ratio)`` for text on surface ``role``."""
return tuple((fg, required)
for fg, surface, required in CONTRAST_RULES
if surface == role)
[docs]
def legible_scrim_floor(theme: str, role: str,
colour_role: Optional[str] = None,
under: Optional[str] = None) -> float:
"""Thinnest scrim for ``role`` that text is still readable over.
Every rule in :data:`CONTRAST_RULES` that paints text on this
surface must clear its WCAG minimum (times :data:`SCRIM_HEADROOM`)
with the surface composited over :func:`scrim_under` — the brightest
thing the theme's wallpaper pipeline can put behind it. Below this
number the panel stops being readable; it is a hard lower bound.
:param theme: name of the palette whose surface is being evaluated.
:param role: palette surface role on which the text is painted.
:param colour_role: palette entry the surface is painted with, when
it differs from ``role`` — ``tile`` is painted with ``surface``.
:param under: what is actually behind the surface, when it is not
the wallpaper. :func:`pane_alpha_floor` passes the flat window
colour for the opaque themes; judging a dark panel over a *dark*
window against a white worst case would report a 0.92 floor for
a surface that is legible at any alpha at all.
"""
palette = palette_for(theme)
base = palette[colour_role or role]
if under is None:
under = scrim_under(theme)
rules = _scrim_rules(colour_role or role)
for step in range(0, 1001):
alpha = step / 1000.0
over_worst = composite(base, alpha, under)
if all(contrast_ratio(palette[fg], over_worst)
>= required * SCRIM_HEADROOM for fg, required in rules):
return alpha
return 1.0
[docs]
def picture_contrast(theme: str, role: str, alpha: float,
colour_role: Optional[str] = None) -> float:
"""How much of the wallpaper survives ``role`` at ``alpha``.
The WCAG ratio between the panel sitting over the brightest thing
the theme can put behind it and the same panel over black: the
dynamic range of the picture as seen *through* the panel. 1.0 is an
opaque panel — no picture at all.
:param theme: the theme name, as passed to :func:`palette_for`.
:param role: the surface role, a key of the theme's palette.
:param alpha: the panel's opacity, 0 to 1.
"""
base = palette_for(theme)[colour_role or role]
return contrast_ratio(composite(base, alpha, scrim_under(theme)),
composite(base, alpha, "#000000"))
[docs]
def present_scrim_ceiling(theme: str, role: str,
colour_role: Optional[str] = None) -> float:
"""Thickest scrim for ``role`` that the picture still reads through.
The largest alpha whose :func:`picture_contrast` is still at least
:data:`MIN_PICTURE_CONTRAST`. Above this number the wallpaper is a
ghost — which is the bug this whole solver exists to close.
:param theme: the theme name, as passed to :func:`palette_for`.
:param role: the surface role, a key of the theme's palette.
"""
for step in range(1000, -1, -1):
alpha = step / 1000.0
if picture_contrast(theme, role, alpha, colour_role) \
>= MIN_PICTURE_CONTRAST:
return alpha
return 0.0
[docs]
def solve_scrim_alpha(theme: str, role: str,
colour_role: Optional[str] = None) -> float:
"""The opacity ``role`` should be painted at in ``theme``.
Two bounds, pulling opposite ways:
* :func:`legible_scrim_floor` is a **lower** bound — thinner than
that and text on the panel stops clearing AA over the worst thing
the wallpaper can present.
* :func:`present_scrim_ceiling` is an **upper** bound — thicker than
that and the picture stops reading through the panel.
The answer is the ceiling, clamped up to the floor: as solid a panel
as the picture can afford, and never thinner than legibility allows.
Every alpha in that window satisfies both constraints, so the choice
within it is which one to spend the slack on, and it goes to the
panel: the settings form sits on this surface, and preserving its grey
category structure is more important than exposing additional wallpaper.
Taking the floor instead would show *more* picture — Cell's floor is 0.05,
a nearly transparent panel — at the cost of the form dissolving into the
wallpaper.
When the floor lands *above* the ceiling the theme cannot do both,
legibility wins, and the shortfall is visible in
:func:`scrim_report`.
:param theme: name of the palette whose surface is being solved.
:param role: palette surface role whose opacity is being chosen.
:param colour_role: palette entry the surface is painted with, when
it differs from ``role`` — ``tile`` is painted with ``surface``.
"""
return max(legible_scrim_floor(theme, role, colour_role),
present_scrim_ceiling(theme, role, colour_role))
#: The roles :func:`_solve_scrims` solves, and the palette entry each
#: one is painted with. ``tile`` is the odd one out: the home-screen
#: tiles are painted with the ``surface`` colour, so they are judged
#: against the ``surface`` rules.
SCRIM_ROLES: Dict[str, str] = {
"surface": "surface",
"surface_alt": "surface_alt",
"surface_hi": "surface_hi",
"tile": "surface",
}
[docs]
def scrim_report(theme: str) -> List[dict]:
"""Both bounds, the solved alpha and what it buys, for every role.
Each entry is ``{"role", "colour_role", "alpha", "floor", "ceiling",
"picture", "worst_fg", "worst_ratio", "required", "legible",
"shows_picture"}``. This is the audit trail for
:data:`SCRIM_ALPHA` — the numbers a reviewer would otherwise have to
re-derive to check that a solved alpha is the right one.
:param theme: the theme name, as passed to :func:`palette_for`.
"""
palette = palette_for(theme)
out: List[dict] = []
for role, colour_role in SCRIM_ROLES.items():
alpha = scrim_alpha(theme, role)
over_worst = composite(palette[colour_role], alpha,
scrim_under(theme))
worst = min(((contrast_ratio(palette[fg], over_worst) / required,
fg, required)
for fg, required in _scrim_rules(colour_role)),
default=(float("inf"), "", 0.0))
picture = picture_contrast(theme, role, alpha, colour_role)
out.append({
"role": role, "colour_role": colour_role, "alpha": alpha,
"floor": legible_scrim_floor(theme, role, colour_role),
"ceiling": present_scrim_ceiling(theme, role, colour_role),
"surface_color": over_worst,
"picture": picture,
"worst_fg": worst[1],
"worst_ratio": worst[0] * worst[2],
"required": worst[2],
"legible": worst[0] >= 1.0,
"shows_picture": picture >= MIN_PICTURE_CONTRAST,
})
return out
[docs]
def scrim_failures(theme: str) -> List[str]:
"""Every role of ``theme`` that cannot be both legible and see-through.
Empty when the theme manages both. A non-empty result is not a
crash — legibility wins and the entry says by how much the picture
misses — but it means the wallpaper is a ghost under that role and
something upstream (the palette, or the exposure the imagery is
solved to) has to give.
:param theme: the theme name, as passed to :func:`palette_for`.
"""
return [
f"{theme}.{row['role']}: alpha {row['alpha']:.3f} shows the picture "
f"at {row['picture']:.2f}:1 < {MIN_PICTURE_CONTRAST:.2f}:1 "
f"(legibility floor {row['floor']:.3f} is above the "
f"see-through ceiling {row['ceiling']:.3f})"
for row in scrim_report(theme)
if not row["shows_picture"]
]
def _solve_scrims() -> Dict[str, Dict[str, float]]:
"""Solve every translucent role of every image theme, once, at import.
Pure colour arithmetic over a thousand-step sweep of four roles and
three themes: a few milliseconds, no Qt, no I/O. Solved rather than
tabulated so that re-hueing a palette moves its scrims with it
instead of silently invalidating a comment.
"""
out: Dict[str, Dict[str, float]] = {}
for name in IMAGE_THEMES:
solved = {role: solve_scrim_alpha(name, role, colour_role)
for role, colour_role in SCRIM_ROLES.items()}
solved["elevated"] = 1.00
out[name] = solved
return out
SCRIM_ALPHA: Dict[str, Dict[str, float]] = {}
[docs]
def scrim_alpha(theme: str, role: str) -> float:
"""Opacity of surface ``role`` in ``theme``. 1.0 unless translucent.
:param theme: the theme name, as passed to :func:`palette_for`.
:param role: the surface role; roles and themes not in ``SCRIM_ALPHA`` give
1.0.
"""
return SCRIM_ALPHA.get(theme, {}).get(role, 1.0)
#: What the preference means at each end. 100 % is a solid panel in the
#: conventional themes. In Glass it is full *material strength*, whose own
#: designed alpha remains translucent.
PANE_OPACITY_MIN = 0.0
PANE_OPACITY_MAX = 1.0
DEFAULT_PANE_OPACITY = 1.0
[docs]
def pane_alpha_floor(theme: str) -> float:
"""Thinnest the Home pane may be painted and stay readable.
An image theme is judged against :func:`scrim_under`, the brightest
thing its wallpaper can put behind the panel. Everything else is
judged against its own flat window colour, which is where the answer
goes to (near) zero: a dark panel fading into a dark window cannot
make white text harder to read, so those themes let the user take
the box away entirely.
:param theme: the theme name, as passed to :func:`palette_for`. Image
themes are judged over :func:`scrim_under`, others over their own
``bg`` colour.
"""
under = (scrim_under(theme) if theme in IMAGE_THEMES
else palette_for(theme)["bg"])
return legible_scrim_floor(theme, "surface", under=under)
[docs]
def pane_alpha(theme: str, opacity: Optional[float] = None) -> float:
"""The alpha a user-controlled page surface is actually painted at.
The user's ``opacity`` (0..1), clamped up to :func:`pane_alpha_floor`.
``None`` means :data:`DEFAULT_PANE_OPACITY`.
:param theme: the theme name, as passed to :func:`palette_for`. Glass
scales the opacity by its designed surface scrim.
"""
if opacity is None:
opacity = DEFAULT_PANE_OPACITY
wanted = max(PANE_OPACITY_MIN, min(PANE_OPACITY_MAX, float(opacity)))
if theme == "glass":
wanted *= scrim_alpha("glass", "surface")
return max(wanted, pane_alpha_floor(theme))
[docs]
def pane_surface(role: str = "surface_alt",
theme: Optional[str] = None,
opacity: Optional[float] = None) -> str:
"""A page-surface colour, already carrying the user's page opacity.
The single accessor every container should use, including the ones styled
inline rather than through :func:`stylesheet`. Those were the gap: Home's
aside panels, the dock and the tile boxes all read
:func:`active_palette` directly, which returns **raw hex**, so they stayed
fully opaque no matter what the preference said and the setting looked
broken from the page the user lands on.
Reads the live preference when ``opacity`` is not given, so a caller does
not have to plumb it through — and falls back to the theme's designed
scrim if preferences cannot be read at all, which is what a first run
mid-generation gets.
:param role: a palette key, normally ``surface``/``surface_alt``/``tile``.
:param theme: theme name; ``None`` resolves the effective one.
:param opacity: 0..1 override; ``None`` reads the preference.
:returns: a QSS colour — plain hex when opaque, ``rgba()`` when not.
"""
if theme is None:
try:
from .preferences import resolve_effective_theme
theme = resolve_effective_theme()
except Exception:
theme = "dark"
if opacity is None:
try:
from .preferences import get_pane_opacity
opacity = get_pane_opacity()
except Exception:
opacity = None
base = palette_for(theme)
colour_role = SCRIM_ROLES.get(role, role)
return css_color(base.get(colour_role, base["surface_alt"]),
panel_alpha(theme, role, opacity))
[docs]
def block_surface(role: str = "surface_alt",
theme: Optional[str] = None,
opacity: Optional[float] = None) -> str:
""":func:`pane_surface` for a registered QSS block: ``None`` IS the scrim.
The two differ in exactly one place, and it matters only there. A block
registered with :func:`register_widget_qss` is handed the ``opacity``
:func:`stylesheet` was called with, and that is ``None`` when the caller
asked for the theme's *designed* scrim rather than any user's setting —
the documented meaning of ``surface_opacity=None``, and what every
built-in rule in this file honours through :func:`panel_alpha`.
:func:`pane_surface` cannot honour it. Its ``None`` means "nobody told me,
go and look", so it reads the live page-opacity preference. That is right
for the inline and paint-time callers it was written for — Home's aside
panels have no ``stylesheet`` argument to plumb through — and wrong inside
a block, where ``None`` was already an answer. The consequence was that
``stylesheet("dark")`` emitted ``rgba(22, 23, 25, 0.600)`` for every block
that passed it through: a function of its arguments quietly depending on a
QSettings value, which made the assertion that the opaque themes emit
plain hex pass or fail on module import order.
No user-visible change. The live path calls :func:`stylesheet` with the
preference already in hand, so ``opacity`` is a number there and the two
functions return the same string; the emitted sheet was compared byte for
byte across every theme at 30 %, 60 % and 100 %.
:param role: a palette key, normally ``surface``/``surface_alt``/``tile``.
:param theme: theme name; ``None`` resolves the effective one.
:param opacity: 0..1, straight from the block's own argument. ``None``
means the theme's designed scrim and is NOT looked up anywhere.
:returns: a QSS colour — plain hex when opaque, ``rgba()`` when not.
"""
if theme is None:
try:
from .preferences import resolve_effective_theme
theme = resolve_effective_theme()
except Exception:
theme = "dark"
base = palette_for(theme)
colour_role = SCRIM_ROLES.get(role, role)
return css_color(base.get(colour_role, base["surface_alt"]),
panel_alpha(theme, role, opacity))
[docs]
def panel_alpha(theme: str, role: str,
opacity: Optional[float] = None) -> float:
"""Apply the page-opacity preference to a shared UI surface role.
``None`` preserves the theme's designed scrim, which keeps
:func:`stylesheet` useful to callers that have no preferences store.
A numeric value is the user's requested alpha and is honoured for every
card, settings section, console and preview surface, clamped only where
going thinner would make that role's text illegible. Glass treats it as
relative material strength, because making 100% an opaque fill would
remove the defining property of the theme.
Popups stay opaque because they are separate native windows; making those
translucent reveals the desktop rather than the spaCR backdrop.
:param theme: the theme name, as passed to :func:`palette_for`.
:param role: the surface role; ``elevated`` is always opaque.
"""
if role == "elevated":
return 1.0
if opacity is None:
return scrim_alpha(theme, role)
wanted = max(PANE_OPACITY_MIN,
min(PANE_OPACITY_MAX, float(opacity)))
colour_role = SCRIM_ROLES.get(role, role)
under = (scrim_under(theme) if theme in IMAGE_THEMES
else palette_for(theme)["bg"])
floor = legible_scrim_floor(
theme, role, colour_role=colour_role, under=under)
if theme == "glass":
wanted *= scrim_alpha("glass", role)
return max(wanted, floor)
#: Exponent of the ease-in applied to TRANSPARENCY across a field's width.
#: 1.0 would be a linear fade, which loses the middle of the field where
#: the value is. 3.0 (cubic) keeps the left half essentially solid and
#: spends the whole fade on the trailing third.
FIELD_FADE_EXPONENT = 3.0
#: How many colour stops the cubic is sampled at when it is handed to a
#: ``QLinearGradient``, which only interpolates linearly between stops.
#: 17 evenly-spaced stops hold the piecewise-linear error under
#: 6*(1/16)**2/8 = 0.003 alpha — below one 8-bit level, so the rendered
#: ramp is the cubic to the last representable bit.
FIELD_FADE_STOPS = 17
[docs]
def field_fade_alpha(t: float) -> float:
"""Alpha of a field's *container* at fraction ``t`` across its width.
``t`` is clamped to [0, 1]. ``field_fade_alpha(0.0)`` is 1.0 — a field
is fully opaque where its value begins, no matter what the page-opacity
preference is set to — and ``field_fade_alpha(1.0)`` is 0.0.
This is the container and its outline only. The text is drawn *after*
the ramp, at full alpha, and never passes through this function.
:param t: fraction of the way across the field, clamped to [0, 1].
"""
t = max(0.0, min(1.0, float(t)))
return 1.0 - t ** FIELD_FADE_EXPONENT
[docs]
def field_fade_profile(stops: int = FIELD_FADE_STOPS):
"""The sampled ramp as ``((t, alpha), ...)``, left edge first.
What a ``QLinearGradient`` is built from, and what a test asserts the
shape of without needing a QApplication.
"""
stops = max(2, int(stops))
return tuple((i / (stops - 1), field_fade_alpha(i / (stops - 1)))
for i in range(stops))
[docs]
def field_chrome(theme: str = "dark") -> Dict[str, object]:
"""Return theme colors and geometry for faded field containers.
Parameters
----------
theme : str, default="dark"
Theme name accepted by :func:`palette_for`.
Returns
-------
dict
Radius, fill, border, focus, and disabled-state tokens. Each color is
represented as ``(hex_color, alpha)``.
Notes
-----
The fade multiplies each token's alpha so translucent themes retain their
material. Field chrome is independent of the page-opacity preference.
"""
base = palette_for(theme)
glass = theme == "glass"
return {
"radius": 10.0 if glass else float(RADIUS["sm"]),
"fill": (base["surface_alt"], 1.0),
"fill_disabled": (base["surface"], 1.0),
"border": (("#ffffff", 0.16) if glass else (base["border"], 1.0)),
"border_focus": (base["accent"], 1.0),
"border_disabled": (("#ffffff", 0.10) if glass
else (base["border_soft"], 1.0)),
}
_SPLASH_DIM_ALPHAS: dict = {}
def _splash_roles(palette: dict) -> dict:
"""The loading screen's colours, derived from the theme's own surface.
The splash used to be ``#003737``, sampled from the installer icon. It
reads as teal because it is teal -- a very dark cyan-green at hue 180 --
and it was the one full-window surface in the application with a colour
cast, shown before anything else was on screen.
It now takes the window's OWN background and foreground, which buys two
things at once. The colour is black on the dark theme, as asked. And the
splash matches the window that replaces it exactly, so the handover has
nothing to flash: a splash one shade off the first painted window is
visible precisely at the moment the loading screen exists to hide.
The ink follows for the same reason it must -- white on the light
theme's near-white surface would be invisible -- and inherits the
contrast the theme already guarantees for body text.
The installer ICON keeps its own colour. This is the splash only.
"""
ink = str(palette.get("fg", "#ffffff"))
bg = str(palette.get("bg", "#000000"))
dim = _SPLASH_DIM_ALPHAS.get((ink, bg))
if dim is None:
if len(_SPLASH_DIM_ALPHAS) >= 256:
_SPLASH_DIM_ALPHAS.clear()
dim = _SPLASH_DIM_ALPHAS[(ink, bg)] = splash_dim_alpha(
ink, bg, target=7.0)
return {
"splash_bg": bg,
"splash_ink": ink,
"splash_ink_dim": _composite(ink, bg, dim),
"splash_track": _composite(ink, bg, 45),
"splash_fill": _composite(ink, bg, 200),
}
[docs]
def splash_dim_alpha(ink: str, bg: str, *, target: float = 3.0,
floor: int = 110) -> int:
"""The lowest alpha at which ``ink`` still reads ``target`` over ``bg``.
Unlit phases are meant to look unreached, so this searches UP from
``floor`` rather than starting bright: dim enough to read as pending,
legible enough to read at all.
:param ink: the text colour, a ``#rgb`` or ``#rrggbb`` colour string.
:param bg: the background colour it is composited over, a ``#rgb`` or
``#rrggbb`` colour string.
"""
for alpha in range(int(floor), 256):
if _contrast(_composite(ink, bg, alpha), bg) >= target:
return alpha
return 255
def _composite(fg: str, bg: str, alpha: int) -> str:
"""``fg`` painted at ``alpha`` over ``bg``, as the painter blends it."""
fr, fg_, fb = _channels(fg, _UNREADABLE)
br, bg_, bb = _channels(bg, _UNREADABLE)
a = max(0, min(255, int(alpha))) / 255.0
return "#%02x%02x%02x" % tuple(
int(round(f * a + b * (1 - a)))
for f, b in ((fr, br), (fg_, bg_), (fb, bb)))
def _relative_luminance(hex_colour: str) -> float:
"""Compute a colour's relative luminance, per WCAG.
:param hex_colour: the colour; an unparseable one is treated as the
unreadable sentinel rather than raising, so a contrast check reports
a failure instead of crashing the theme.
:returns: the luminance in ``[0, 1]``.
"""
def channel(value: int) -> float:
"""One sRGB channel linearised, per WCAG's own definition."""
v = value / 255.0
return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
r, g, b = (channel(c) for c in _channels(hex_colour, _UNREADABLE))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def _contrast(a: str, b: str) -> float:
"""Return the WCAG contrast ratio between two colours.
:param a: one colour.
:param b: the other.
:returns: the ratio, from 1 (identical) to 21 (black on white). Order
does not matter -- the lighter is always the numerator.
"""
la, lb = _relative_luminance(a), _relative_luminance(b)
hi, lo = max(la, lb), min(la, lb)
return (hi + 0.05) / (lo + 0.05)
#: Where each palette role lands on the spectrum, in degrees of hue.
#:
#: The surfaces sweep it — violet window, magenta page, blue and cyan and
#: green panels — because those are the large areas, and they are what makes
#: the application read as rainbow rather than as a blue application with
#: coloured buttons.
#:
#: The STATUS roles deliberately do not sweep. `error` stays at the red end,
#: `warning` in the ambers and `success` in the greens, because their whole
#: job is to be recognised before they are read; a dressing that makes a
#: failure look like a success is a broken theme, not a trippy one. They are
#: re-hued — five degrees, fifty, a hundred and thirty — so they belong to
#: the same spectrum as everything else, but they stay in their own
#: neighbourhood of it.
#:
#: `info` tracks `accent` and `chip_value` tracks `success`, the way they
#: already do in every shipped palette.
SPACEOUT_HUES: Dict[str, float] = {
"bg": 285.0,
"page": 300.0,
"surface": 250.0,
"surface_alt": 205.0,
"surface_hi": 165.0,
"border": 320.0,
"border_soft": 265.0,
"fg": 210.0,
"fg_muted": 45.0,
"fg_dim": 25.0,
"accent": 190.0,
"accent_hi": 175.0,
"accent_lo": 215.0,
"accent_soft": 275.0,
"info": 190.0,
"success": 130.0,
"warning": 50.0,
"error": 5.0,
"chip_class": 165.0,
"chip_value": 115.0,
"button_accent": 305.0,
"button_accent_hi": 320.0,
"button_accent_lo": 290.0,
"button_accent_ink": 340.0,
}
#: How far toward its hue a role is taken. Everything not listed goes all
#: the way.
#:
#: The two that are held back are the two the ANIMATION IS PAINTED ONTO, and
#: the number is measured rather than judged. On a dark page the ambient
#: backdrop composites ADDITIVELY, so what reaches the eye is the page's own
#: channels plus the animation's. At full saturation the dressed page is
#: ``#480048`` — 72 of red and 72 of blue and none of green — and the
#: fractal's green at its peak alpha adds 67. The green never wins, and the
#: measured result was a rainbow palette rendering as four neighbouring
#: hues: blue through magenta to red, and nothing else, whatever the
#: animation was drawing underneath.
#:
#: Damped to a third, the same luminance is spread across all three channels
#: instead of piled into two, and every colour the animation draws clears
#: it. The page is still unmistakably not the ordinary grey — it is a plum —
#: and the rainbow it was hiding is now visible. See
#: ``tests/qt/test_spaceout_fractals_move_and_stay_in_budget.py``, which
#: counts the hue families in a real painted frame.
SPACEOUT_SATURATION: Dict[str, float] = {
"bg": 0.35,
"page": 0.35,
}
def _hue_rgb(hue: float, saturation: float = 1.0) -> Tuple[float, float,
float]:
"""sRGB for ``hue`` in degrees at ``saturation``, as 0..1 per channel.
The HSV ``V=1`` plane, written out rather than imported so this module
keeps its short import list. ``saturation`` 1.0 is the pure hue; 0.0 is
white, and every value between mixes the two, which is what
:data:`SPACEOUT_SATURATION` asks for.
"""
position = (float(hue) % 360.0) / 60.0
ramp = 1.0 - abs(position % 2.0 - 1.0)
pure = ((1.0, ramp, 0.0), (ramp, 1.0, 0.0), (0.0, 1.0, ramp),
(0.0, ramp, 1.0), (ramp, 0.0, 1.0), (1.0, 0.0, ramp))[
int(position) % 6]
weight = max(0.0, min(1.0, float(saturation)))
return tuple(1.0 - weight * (1.0 - channel) for channel in pure)
#: The sRGB transfer function, tabulated for all 256 levels.
#:
#: :func:`_hue_shift` scores 512 candidate colours per ``(colour, hue)``
#: pair, and doing that through :func:`_relative_luminance` would mean
#: formatting each one to hex and parsing it straight back — a hundredfold
#: on the only part of this that is not free.
_LINEAR_CHANNEL: Tuple[float, ...] = tuple(
(level / 255.0) / 12.92 if level / 255.0 <= 0.03928
else ((level / 255.0 + 0.055) / 1.055) ** 2.4
for level in range(256))
def _rgb_luminance(rgb: Tuple[int, int, int]) -> float:
"""WCAG relative luminance of an 8-bit ``(r, g, b)`` triple."""
return (0.2126 * _LINEAR_CHANNEL[rgb[0]]
+ 0.7152 * _LINEAR_CHANNEL[rgb[1]]
+ 0.0722 * _LINEAR_CHANNEL[rgb[2]])
#: Levels either side of the crossing :func:`_hue_shift` measures.
#:
#: The two ramps are monotone but only weakly: 8-bit rounding leaves short
#: plateaus where two neighbouring levels land on the same luminance, and a
#: linear scan keeping the first strict improvement returns the LOWEST index
#: on such a plateau. Eight is far wider than any plateau either ramp can
#: produce — a fully saturated hue moves at least one channel on every
#: level — so the window contains the whole tie and the lowest index in it
#: still wins.
_HUE_WINDOW = 8
@lru_cache(maxsize=None)
def _hue_shift(colour: str, hue: float, saturation: float = 1.0) -> str:
"""``colour`` moved to ``hue``, at the relative luminance it already had.
Two ramps are searched, and both are needed because a saturated hue can
only reach part of the luminance scale — fully saturated blue tops out
at 0.0722, and the light theme's ``surface`` is 1.0:
* **value**, at full saturation: the hue's own colour, darkened. This is
the arm that answers for the surfaces and for most of the ink, and it
is the one that makes the result *look* like a rainbow.
* **saturation**, at full value: the hue mixed toward white, for the
roles that need more light than the hue itself carries. This is what
keeps a white ``fg`` white instead of substituting a violet nobody
could read a settings form in.
The closest of all 512 candidates wins, so the 8-bit rounding error is
minimised rather than merely bounded. Cached because ``palette_for`` is
on the path of every stylesheet build and every widget that paints.
THE 512 ARE NOT ALL VISITED. Both ramps are monotone in luminance —
every channel of the value ramp rises with the level and every channel
of the saturation ramp falls with the step — so the closest entry is
found by crossing rather than by scanning, and only a window either side
of the crossing is measured. That is 20-odd candidates instead of 512
and it is not an approximation: ``test_spaceout_looks_alive.py`` asserts
the answer is identical to the full scan for every colour, hue and
saturation the dressing can produce. It matters because the drift asks
for the whole palette at :data:`SPACEOUT_DRIFT_STEPS` offsets rather
than once, and the full scan spent a second of the launcher's startup
doing it.
"""
target = _relative_luminance(colour)
base = _hue_rgb(hue, saturation)
def value(level: int) -> Tuple[int, int, int]:
"""The base hue at ``level``, darkened toward black."""
return (int(round(base[0] * level)),
int(round(base[1] * level)),
int(round(base[2] * level)))
def tint(step: int) -> Tuple[int, int, int]:
"""The base hue at ``step``, lightened toward white."""
weight = step / 255.0
return (int(round(255.0 * (1.0 - weight + weight * base[0]))),
int(round(255.0 * (1.0 - weight + weight * base[1]))),
int(round(255.0 * (1.0 - weight + weight * base[2]))))
best, error = value(0), abs(_rgb_luminance(value(0)) - target)
for ramp, rising in ((value, True), (tint, False)):
low, high = 0, 255
while low < high:
mid = (low + high) // 2
here = _rgb_luminance(ramp(mid))
if (here < target) if rising else (here > target):
low = mid + 1
else:
high = mid
for index in range(max(0, low - _HUE_WINDOW),
min(256, low + _HUE_WINDOW + 1)):
candidate = ramp(index)
miss = abs(_rgb_luminance(candidate) - target)
if miss < error:
best, error = candidate, miss
return "#%02x%02x%02x" % best
[docs]
def spaceout_palette(palette: dict, drift: float = 0.0,
theme: Optional[str] = None) -> dict:
"""Re-hue a theme palette while preserving accessible luminance.
Roles absent from :data:`SPACEOUT_HUES` are returned unchanged. Named ink
roles are constrained to contrast-safe luminance bands for ``theme``.
:param palette: Mapping from theme roles to colour values.
:param drift: Hue rotation in degrees applied to all mapped roles.
:param theme: Theme used to resolve contrast-safe ink bands, or ``None``
for a direct hue shift.
:returns: A new role-to-colour mapping.
"""
bands = _INK_BANDS.get(theme, {}) if theme else {}
damping = _PAGE_DAMPING.get(theme, {}).get(float(drift), 1.0) \
if theme else 1.0
out = {}
for role, colour in palette.items():
seat = SPACEOUT_HUES.get(role)
if seat is None:
out[role] = colour
continue
hue = (seat + float(drift)) % 360.0
band = bands.get(role)
if band:
out[role] = _hue_ink(hue, band[0], band[1], colour)
continue
saturation = SPACEOUT_SATURATION.get(role, 1.0)
if role in SPACEOUT_DAMPED_ROLES:
saturation *= damping
out[role] = _hue_shift(colour, hue, saturation)
return out
#: Seconds for the spectrum to travel once round, before the wander.
#:
#: Nine minutes. A backdrop must never look like it is *moving*, only like
#: it has moved when you look back at it — the same figure the blob drift
#: and the fractal's own spin are set by.
SPACEOUT_DRIFT_TURN = 540.0
#: The wander, as ``(share of a turn, period in seconds, phase in turns)``.
#:
#: WHAT MAKES IT NOT A LOOP. On its own the term above is a metronome: the
#: hue advances by the same amount every second and a watcher learns the
#: cycle. These three add a wander whose periods are mutually incommensurate
#: with each other and with the turn, so the *sequence* of hues — fast here,
#: backing up there, dwelling somewhere else — does not repeat on any period
#: short enough to be learned. They are amplitudes on the ANGLE, so the
#: drift can slow, stall and briefly reverse without ever jumping.
SPACEOUT_DRIFT_WANDER: Tuple[Tuple[float, float, float], ...] = (
(0.070, 149.0, 0.137),
(0.041, 76.3, 0.611),
(0.023, 31.7, 0.283),
)
#: How many hue offsets the *palette* is allowed to take.
#:
#: The drift itself is continuous — :func:`spaceout_drift` — and the things
#: that repaint every frame use it that way. The palette is quantised onto
#: this grid instead, for one reason: every offset on it has to be SOLVED,
#: because the scrim alphas and the ink bands below are worst cases over the
#: offsets the palette can actually reach. A continuous palette would be a
#: continuum of solves.
#:
#: Sixty is six degrees a step and one step every nine seconds. Six degrees
#: moves a saturated surface by two or three 8-bit levels, which is under
#: the step the eye resolves on a large flat area, and the QSS chrome only
#: re-reads the palette when the stylesheet is rebuilt anyway.
SPACEOUT_DRIFT_STEPS = 60
#: The roles whose colour is solved for CHROMA rather than carried over.
#:
#: "the color of the text is pretty good but could be more rainbow like."
#: The reason it was not is the identity the rest of the dressing rests on:
#: hue moves, luminance does not — and a role already at the top of the
#: luminance scale cannot carry a hue at all. Dark's `fg` is ``#ffffff``, so
#: re-hueing it returns ``#ffffff``, and the body text of the application is
#: the one thing in it that was not in the rainbow.
#:
#: So for these three the luminance is allowed to MOVE, inside a band solved
#: from :data:`CONTRAST_RULES` — see :func:`_ink_band` — and the most
#: chromatic colour on the hue line inside that band is taken. On dark that
#: turns ``#ffffff`` into a fully saturated ``#00d5ff`` at the hue the table
#: gives `fg`, and it still clears 4.5:1 on every surface with
#: :data:`SPACEOUT_INK_HEADROOM` to spare.
#:
#: THE CHECK IS WHAT DECIDES HOW FAR IT GOES. Where the band is narrow the
#: answer is a pale tint, and that is the right answer: a trippy theme that
#: cannot be read is a broken theme.
SPACEOUT_INK_ROLES: Tuple[str, ...] = ("fg", "fg_muted", "fg_dim")
#: Multiplier on every WCAG minimum when solving an ink band, so a solved
#: ink is not sitting exactly on the line — the same reason
#: :data:`SCRIM_HEADROOM` exists, and much larger than it, for two reasons
#: that both come from what is UNDER the text.
#:
#: The scrims are re-solved AFTER the ink and must not be able to push it
#: under, which is the small half. The large half is that
#: :data:`CONTRAST_RULES` judges ink against a surface role, and some panels
#: in the application are painted translucent by the WIDGET rather than by
#: the theme — ``SetupCard`` lays its body down at alpha 216 so the backdrop
#: shows through it, and under ``spaceout`` that backdrop is a bright
#: fractal. Measured on the rendered first-run card over a real frame: at
#: 1.12 the heading came out at 4.56:1 against a 4.5:1 rule, which is inside
#: the rule and outside any comfort. At 1.30 the same measurement is 5.6:1
#: and ``fg`` is still a saturated blue rather than the white it was.
SPACEOUT_INK_HEADROOM = 1.30
#: The saturations tried when the drift breaks the page separation, in
#: order. The first one that clears the rule wins, so a drift offset that
#: never had a problem keeps the full colour.
#:
#: WHY THIS EXISTS, and it is the same reason the scrims are re-solved. The
#: contrast rules survive a re-hue by construction, because a ratio is a
#: function of relative luminance alone — but `page_separation_report` asks
#: whether you can SEE the panel, and half of its rows composite the panel
#: over the page at :data:`PAGE_FADED_OPACITY`. That composite happens
#: channel by channel in sRGB, and two colours of equal luminance and
#: different hue do not composite to equal luminance. Measured over the
#: sixty offsets the palette can take: the light theme's faded
#: ``surface_alt`` drops to 1.069:1 against a 1.08:1 rule at four of them.
#:
#: So at those offsets — and only at those — the page and the panels are
#: mixed back toward white until the panel separates again. It costs
#: saturation on four sixtieths of the drift and it buys a page you can
#: still see the panels on, which is the trade the request names outright.
SPACEOUT_DAMPING_STEPS: Tuple[float, ...] = (1.0, 0.75, 0.55, 0.40, 0.28,
0.20, 0.12)
#: Extra saturation damping for the page and its panels, as
#: ``{theme: {drift offset: multiplier}}``. An offset that is not in the
#: table needs no damping, which is nearly all of them.
_PAGE_DAMPING: Dict[str, Dict[float, float]] = {}
#: Solved damping, keyed by whether the dressing is on — the twin of
#: :data:`_SOLVED_SCRIMS`.
_SOLVED_DAMPING: Dict[bool, Dict[str, Dict[float, float]]] = {}
#: The roles the damping reaches: the page itself and the panels that have
#: to stay visible on it.
#:
#: Written out rather than built from :data:`PAGE_PANEL_ROLES`, which is
#: declared further down the module; ``test_spaceout_looks_alive.py`` asserts
#: the two agree so the pair cannot drift apart.
SPACEOUT_DAMPED_ROLES: Tuple[str, ...] = ("page", "surface", "surface_alt")
#: Elapsed animation seconds the drift is at. Advanced by the widgets that
#: are already painting frames; never read off a wall clock.
_DRIFT_SECONDS = 0.0
#: Solved ink bands for the current dressing, ``{theme: {role: (lo, hi)}}``.
#: Empty when the dressing is off, which is what makes every ink role fall
#: back to the plain hue shift.
_INK_BANDS: Dict[str, Dict[str, Tuple[float, float]]] = {}
#: Solved ink bands, keyed by whether the dressing is on — the twin of
#: :data:`_SOLVED_SCRIMS`, and cached for the same reason.
_SOLVED_INK: Dict[bool, Dict[str, Dict[str, Tuple[float, float]]]] = {}
#: The themes whose dressing is solved the moment the dressing goes on,
#: rather than the first time somebody asks for that theme's palette.
#:
#: THIS IS WHY THE TEN NIGHT THEMES DID NOT COST THE SPACEOUT LAUNCHER HALF
#: A SECOND. The damping solve is 60 offsets per theme and the ink-band
#: solve another 60, so both are linear in the number of themes, and both
#: ran over all of them at the moment ``spaceout`` started: going from four
#: themes to fourteen took :func:`enable_spaceout` from 417-438 ms to
#: 864-876 ms, measured, before the window appears. Only the theme the user
#: is actually in gets dressed, so thirteen of those solves were for a
#: palette that run would never paint.
#:
#: The four here are solved eagerly because they are what the process can
#: be in without anybody choosing: dark and light are what ``"system"``
#: resolves to, and cell and glass are the two image themes, whose scrims
#: are solved over the drift in the same pass anyway. Everything else is
#: solved by :func:`_dress_theme` the first time its palette is asked for,
#: which is once and then never again.
DRESSED_EAGERLY: Tuple[str, ...] = ("dark", "light", "cell", "glass")
#: Themes solved so far under the current dressing. Reset whenever the
#: dressing changes, because the bands and the damping are both functions
#: of it.
_DRESSED: set = set()
def _dress_theme(name: str) -> None:
"""Solve one theme's ink bands and page damping, once.
WHAT STOPS THIS RECURSING is the ``_DRESSED`` entry, and it is added
BEFORE the two solves rather than after. Every solver resolves palettes
through :func:`palette_for`, so a solve for ``name`` re-enters here for
``name``; finding itself already in the set, it returns at once. Moving
that line below the solves would spin.
:func:`palette_for` also calls this only when ``_SOLVE_DRIFT is None``,
which is "no solve is running". THAT CHECK IS A SECOND BELT AND IS
REDUNDANT, and is recorded as such rather than left to look
load-bearing: removing it turns no test red, because a solve only ever
asks for the palette of the theme it is solving, which the set already
covers. It is kept because it costs one comparison and it is what keeps
the property true if a future solver reaches for a second theme.
The result goes into the live tables AND into the
:data:`_SOLVED_INK` / :data:`_SOLVED_DAMPING` caches, so taking the
dressing off and putting it back stays the dict copy it was and does
not silently lose a theme that had been solved.
:param name: a key of :data:`_PALETTES`; anything else is ignored,
because :func:`palette_for` falls back to dark for those and dark
is solved already.
"""
if not _SPACEOUT or name in _DRESSED or name not in _PALETTES:
return
_DRESSED.add(name)
damping = _solve_page_damping((name,))
bands = _solve_ink_bands((name,))
_INK_BANDS.update(bands)
cached_bands = _SOLVED_INK.get(True)
if cached_bands is not None:
cached_bands.update(bands)
cached_damping = _SOLVED_DAMPING.get(True)
if cached_damping is not None:
cached_damping.update(damping)
#: While a solve is running: the hue offset to dress at, and whether the ink
#: treatment is applied. `palette_for` consults both, which is what lets the
#: solvers call the ordinary public helpers — `effective_surface`,
#: `scrim_under`, `max_background_luma` — instead of restating them, and
#: what stops the ink solve recursing into the palette it is solving.
_SOLVE_DRIFT: Optional[float] = None
_SOLVE_INK = True
@contextmanager
def _dressed_at(drift: float, ink: bool = True):
"""Resolve palettes at hue offset ``drift`` for the duration.
``ink`` False leaves the ink roles on the plain hue shift, which is what
:func:`_ink_band` needs: it is solving the band the ink will be chosen
from, and it reads the surfaces through :func:`effective_surface`, which
goes back through :func:`palette_for`.
"""
global _SOLVE_DRIFT, _SOLVE_INK
was = (_SOLVE_DRIFT, _SOLVE_INK)
_SOLVE_DRIFT, _SOLVE_INK = float(drift), bool(ink)
try:
yield
finally:
_SOLVE_DRIFT, _SOLVE_INK = was
def _drift_grid() -> Tuple[float, ...]:
"""The hue offsets the palette can take, in degrees."""
return tuple(index * 360.0 / SPACEOUT_DRIFT_STEPS
for index in range(SPACEOUT_DRIFT_STEPS))
[docs]
def spaceout_drift(at: Optional[float] = None) -> float:
"""Return the continuous spaceout hue rotation in degrees.
:param at: Elapsed animation time in seconds. ``None`` uses the current
drift clock.
:returns: Hue rotation in ``[0, 360)``, or zero when spaceout is disabled.
"""
if not _SPACEOUT:
return 0.0
elapsed = _DRIFT_SECONDS if at is None else float(at)
turns = elapsed / SPACEOUT_DRIFT_TURN
for share, period, phase in SPACEOUT_DRIFT_WANDER:
turns += share * math.sin(2.0 * math.pi * (elapsed / period + phase))
return (turns * 360.0) % 360.0
[docs]
def spaceout_drift_step(at: Optional[float] = None) -> float:
"""Return :func:`spaceout_drift` quantised to its solved palette grid."""
if not _SPACEOUT:
return 0.0
step = 360.0 / SPACEOUT_DRIFT_STEPS
return (round(spaceout_drift(at) / step) % SPACEOUT_DRIFT_STEPS) * step
[docs]
def spaceout_drift_seconds() -> float:
"""Return the elapsed spaceout animation time in seconds."""
return _DRIFT_SECONDS
[docs]
def advance_spaceout_drift(dt: float) -> float:
"""Advance the spaceout clock and return the resulting hue rotation.
Non-positive intervals and calls made while spaceout is disabled do not
modify the clock.
:param dt: seconds to add to the spaceout clock; ignored when not positive
or when spaceout is off.
"""
global _DRIFT_SECONDS
if _SPACEOUT and dt > 0:
_DRIFT_SECONDS += float(dt)
return spaceout_drift()
[docs]
def set_spaceout_drift_seconds(seconds: float) -> None:
"""Set the spaceout animation clock, clamped to zero or greater.
:param seconds: the new clock value, in seconds; negative values become 0.
"""
global _DRIFT_SECONDS
_DRIFT_SECONDS = max(0.0, float(seconds))
@lru_cache(maxsize=None)
def _hue_ink(hue: float, low: float, high: float, fallback: str) -> str:
"""The most chromatic colour on ``hue`` whose luminance is in the band.
The same 512 candidates :func:`_hue_shift` scores — the value ramp at
full saturation and the saturation ramp at full value — judged on a
different question. :func:`_hue_shift` asks which one is closest to a
luminance it must keep; this asks which one is the most COLOURED of
those the readability band allows, because for the ink the luminance is
the constraint and the colour is the point.
Falls back to the plain hue shift when the band admits nothing, which is
what a role whose rules leave it no room gets: unchanged and readable.
"""
base = _hue_rgb(hue)
best: Optional[Tuple[int, int, int]] = None
chroma = -1
for level in range(256):
candidate = (int(round(base[0] * level)),
int(round(base[1] * level)),
int(round(base[2] * level)))
if low <= _rgb_luminance(candidate) <= high:
spread = max(candidate) - min(candidate)
if spread > chroma:
best, chroma = candidate, spread
for step in range(256):
weight = step / 255.0
candidate = (
int(round(255.0 * (1.0 - weight + weight * base[0]))),
int(round(255.0 * (1.0 - weight + weight * base[1]))),
int(round(255.0 * (1.0 - weight + weight * base[2]))))
if low <= _rgb_luminance(candidate) <= high:
spread = max(candidate) - min(candidate)
if spread > chroma:
best, chroma = candidate, spread
if best is None:
return _hue_shift(fallback, hue)
return "#%02x%02x%02x" % best
def _ink_band(theme: str, role: str) -> Optional[Tuple[float, float]]:
"""The luminances ``role`` may take in ``theme`` and still be read.
Closed form per rule rather than a search. For an ink of luminance
``L`` on a surface of luminance ``Ls``, WCAG asks
``(hi + 0.05) / (lo + 0.05) >= r``; an ink that is the LIGHTER of the
pair is therefore bounded below by ``(Ls + 0.05) * r - 0.05`` and a
darker one bounded above by ``(Ls + 0.05) / r - 0.05``. The band is the
tightest of those over every rule in :data:`CONTRAST_RULES` that names
the role, at every offset the drift can reach — the surfaces keep their
luminance under the dressing, but an image theme's surfaces are
composited over the wallpaper and those do move with hue.
One more bound, and it is what keeps the rest of the module honest:
:func:`max_background_luma` is a minimum over ink luminances, and the
imagery pipeline exposure-solves every wallpaper down to it. Letting the
ink darken would silently darken every photograph in the application, so
each role that feeds that minimum is held at or above the luminance it
needs to leave the ceiling where it was.
``None`` when the role sits between its surfaces, or when the bounds
cross — both of which mean there is no room to spend and the plain hue
shift is the right answer.
"""
rules = tuple((surface, required)
for fg, surface, required in CONTRAST_RULES if fg == role)
if not rules:
return None
plain = dict(_PALETTES.get(theme, DARK_PALETTE))
plain.update(CONSTANT_ROLES)
ink = relative_luminance(plain[role])
low, high = 0.0, 1.0
for drift in _drift_grid():
with _dressed_at(drift, ink=False):
surfaces = [relative_luminance(effective_surface(theme, surface))
for surface, _ in rules]
if ink >= max(surfaces):
for (_surface, required), luma in zip(rules, surfaces):
low = max(low, (luma + 0.05) * required
* SPACEOUT_INK_HEADROOM - 0.05)
elif ink <= min(surfaces):
for (_surface, required), luma in zip(rules, surfaces):
high = min(high, (luma + 0.05) / (required
* SPACEOUT_INK_HEADROOM)
- 0.05)
else:
return None
with _dressed_at(0.0, ink=False):
keep = max_background_luma(theme)
for named, required in BARE_IMAGE_RULES:
if named == role:
low = max(low, (keep + 0.05) * required - 0.05)
low, high = max(0.0, low), min(1.0, high)
return (low, high) if high - low > 1e-6 else None
def _solve_page_damping(names: Optional[Tuple[str, ...]] = None
) -> Dict[str, Dict[float, float]]:
"""How much colour each theme has to give up, at each drift offset, for
its panels to stay visible on its page.
Solved by *trying* rather than by arithmetic, because the rule it is
solving against — :func:`page_separation_failures` — is two
measurements, one of them in CIE L*, and reading them backwards to a
saturation would be a second implementation of the thing it has to
agree with. Seven candidates over sixty offsets is about 28 ms per
theme.
The candidate under test is written straight into :data:`_PAGE_DAMPING`
so :func:`page_separation_failures` sees it through the palette, which
is what makes this the published rule judging the published colours
rather than a copy of either.
:param names: the themes to solve. ``None`` solves :data:`THEMES` and
clears anything already there, which is the whole-dressing case;
naming themes leaves the rest of the table alone, which is what
:func:`_dress_theme` needs.
:returns: the solved rows for the themes asked for.
"""
if names is None:
names = THEMES
_PAGE_DAMPING.clear()
solved: Dict[str, Dict[float, float]] = {}
for name in names:
rows: Dict[float, float] = {}
_PAGE_DAMPING[name] = rows
for drift in _drift_grid():
for damping in SPACEOUT_DAMPING_STEPS:
rows[drift] = damping
with _dressed_at(drift):
if not page_separation_failures(name):
break
if rows[drift] >= 1.0:
del rows[drift]
solved[name] = dict(rows)
return solved
def _solve_ink_bands(names: Optional[Tuple[str, ...]] = None
) -> Dict[str, Dict[str, Tuple[float, float]]]:
"""Every ink band of the themes asked for.
:param names: the themes to solve; ``None`` means :data:`THEMES`.
:returns: ``{theme: {role: (low, high)}}``.
"""
out: Dict[str, Dict[str, Tuple[float, float]]] = {}
for name in (THEMES if names is None else names):
rows = {}
for role in SPACEOUT_INK_ROLES:
band = _ink_band(name, role)
if band is not None:
rows[role] = band
out[name] = rows
return out
def _scrim_bounds(palette: dict, role: str, colour_role: str,
under: Tuple[int, int, int]) -> Tuple[float, float]:
""":func:`legible_scrim_floor` and :func:`present_scrim_ceiling`, in one
pass over 8-bit channels rather than over hex strings.
Exactly the two published solvers and it has to stay exactly them —
``tests/qt/test_spaceout_looks_alive.py`` asserts the answers match for
every role of every image theme. What it is not is their cost: those
format a colour to hex and parse it straight back once per step of a
thousand-step sweep, and the drift asks for the pair at every offset in
:func:`_drift_grid` rather than once.
The ceiling is found coarse-to-fine. :func:`picture_contrast` falls as
the panel thickens, so the first coarse step that still shows the
picture puts the answer inside the block above it, and the block is then
walked from the top. Not a bisection: the fall is monotone in the reals
but 8-bit rounding makes it wobble by two or three thousandths, and a
bisection lands on the wrong side of the wobble.
"""
base = _channels(palette[colour_role or role])
inks = tuple((_rgb_luminance(_channels(palette[fg])),
required * SCRIM_HEADROOM)
for fg, required in _scrim_rules(colour_role or role))
def over(alpha: float, beneath: Tuple[int, int, int]) -> float:
"""The luminance of the scrim at ``alpha`` over one background."""
rest = 1.0 - alpha
return _rgb_luminance((
int(round(alpha * base[0] + rest * beneath[0])),
int(round(alpha * base[1] + rest * beneath[1])),
int(round(alpha * base[2] + rest * beneath[2]))))
floor = 1.0
for step in range(0, 1001):
panel = over(step / 1000.0, under)
if all((max(luma, panel) + 0.05) / (min(luma, panel) + 0.05) >= need
for luma, need in inks):
floor = step / 1000.0
break
def shows(step: int) -> bool:
"""Whether text still meets the contrast floor at this scrim strength.
Checked against BOTH the lightest and the darkest thing the scrim can
sit on, because a picture backdrop is neither -- an alpha that reads
against one and not the other is not usable.
"""
alpha = step / 1000.0
lit, dark = over(alpha, under), over(alpha, (0, 0, 0))
return ((max(lit, dark) + 0.05) / (min(lit, dark) + 0.05)
>= MIN_PICTURE_CONTRAST)
coarse = 1000
while coarse > 0 and not shows(coarse):
coarse -= 25
ceiling = 0.0
for step in range(min(1000, coarse + 25), max(-1, coarse - 1), -1):
if shows(step):
ceiling = step / 1000.0
break
return floor, ceiling
def _solve_scrims_over_drift() -> Dict[str, Dict[str, float]]:
"""Scrim alphas that hold at every offset the drift can reach.
The bounds :func:`solve_scrim_alpha` weighs are the same two, taken as a
worst case instead of at one palette: the HIGHEST legibility floor and
the LOWEST see-through ceiling over :func:`_drift_grid`. The answer is
still the ceiling clamped up to the floor, so where the drift makes the
two cross, legibility takes it — which is the same way round this module
has always resolved that pair.
"""
out: Dict[str, Dict[str, float]] = {}
for name in IMAGE_THEMES:
floors: Dict[str, float] = {}
ceilings: Dict[str, float] = {}
for drift in _drift_grid():
with _dressed_at(drift):
palette = palette_for(name)
under = _channels(scrim_under(name))
for role, colour_role in SCRIM_ROLES.items():
floor, ceiling = _scrim_bounds(palette, role, colour_role,
under)
floors[role] = max(floors.get(role, 0.0), floor)
ceilings[role] = min(ceilings.get(role, 1.0), ceiling)
solved = {role: max(ceilings[role], floors[role])
for role in SCRIM_ROLES}
solved["elevated"] = 1.00
out[name] = solved
return out
#: Whether this process is wearing the spaceout dressing. Process state,
#: never a stored preference — see the block above.
_SPACEOUT = False
#: Solved scrim alphas, keyed by whether the dressing is on. Populated at
#: import for ``False`` and on the first :func:`enable_spaceout` for
#: ``True``, so flipping the mode costs one solve and never more.
_SOLVED_SCRIMS: Dict[bool, Dict[str, Dict[str, float]]] = {}
[docs]
def spaceout_enabled() -> bool:
"""Return whether spaceout rendering is enabled for this process."""
return _SPACEOUT
def _apply_dressing() -> None:
"""Point :data:`SCRIM_ALPHA` and :data:`_INK_BANDS` at the current
dressing's solved values.
The image themes paint translucent panels, so their alphas are a
function of the palette — re-hueing one moves the colour a panel is
composited from and therefore what it takes for text to stay readable
over the wallpaper. Under the dressing the palette also *drifts*, so the
answer is a worst case over the offsets it can reach rather than a
single solve.
TWO PASSES, and the order is forced. The ink band is read off the
surfaces, and an image theme's surfaces are its scrims composited over
the wallpaper — so the scrims have to exist before the ink can be
solved. The ink then changes what those panels have to carry, so the
scrims are solved again against it. :data:`SPACEOUT_INK_HEADROOM` is
what stops the second pass from invalidating the first: the ink is
solved with 12 % in hand, and the scrims only ever thicken.
Solved once per dressing and cached, so taking it off and putting it
back costs one dict copy.
"""
solved = _SOLVED_SCRIMS.get(_SPACEOUT)
bands = _SOLVED_INK.get(_SPACEOUT)
damping = _SOLVED_DAMPING.get(_SPACEOUT)
if solved is None or bands is None or damping is None:
if not _SPACEOUT:
solved, bands, damping = _solve_scrims(), {}, {}
else:
_INK_BANDS.clear()
_DRESSED.clear()
_DRESSED.update(DRESSED_EAGERLY)
damping = _solve_page_damping(DRESSED_EAGERLY)
SCRIM_ALPHA.clear()
SCRIM_ALPHA.update(_solve_scrims_over_drift())
bands = _solve_ink_bands(DRESSED_EAGERLY)
_INK_BANDS.update(bands)
solved = _solve_scrims_over_drift()
_SOLVED_SCRIMS[_SPACEOUT] = solved
_SOLVED_INK[_SPACEOUT] = bands
_SOLVED_DAMPING[_SPACEOUT] = damping
SCRIM_ALPHA.clear()
SCRIM_ALPHA.update(solved)
_INK_BANDS.clear()
_INK_BANDS.update(bands)
_PAGE_DAMPING.clear()
_PAGE_DAMPING.update({name: dict(rows) for name, rows in damping.items()})
[docs]
def enable_spaceout() -> None:
"""Enable process-local spaceout rendering.
This operation is idempotent and does not modify saved preferences.
"""
global _SPACEOUT, _DRIFT_SECONDS
if _SPACEOUT:
return
_SPACEOUT = True
_DRIFT_SECONDS = 0.0
_apply_dressing()
[docs]
def disable_spaceout() -> None:
"""Disable process-local spaceout rendering and reset its clock."""
global _SPACEOUT, _DRIFT_SECONDS
if not _SPACEOUT:
return
_SPACEOUT = False
_DRIFT_SECONDS = 0.0
_apply_dressing()
[docs]
def palette_for(theme: str = "dark") -> dict:
"""Return the palette dict for ``theme``.
``theme`` is one of :data:`THEMES`; anything else (including
``"system"``, which the caller is expected to have resolved) falls
back to the dark palette. The returned dict always carries every
theme-invariant key from :data:`CONSTANT_ROLES` so callers can hit
e.g. ``palette_for(t)["button_accent"]`` and know the value is the
same across themes.
Under the ``spaceout`` dressing (:func:`spaceout_enabled`) the result is
re-hued onto the spectrum on the way out, at whatever offset the drift
has reached. The keys and the count are unchanged, and so is every
surface role's relative luminance, so callers, contrast rules and the
light/dark distinction all go on working. The three ink roles are the
exception and are solved rather than carried — see
:data:`SPACEOUT_INK_ROLES`.
"""
base = _PALETTES.get(theme, DARK_PALETTE)
out = dict(base)
out.update(CONSTANT_ROLES)
if _SPACEOUT:
if _SOLVE_DRIFT is None:
_dress_theme(theme)
drift = (spaceout_drift_step() if _SOLVE_DRIFT is None
else _SOLVE_DRIFT)
out = spaceout_palette(out, drift, theme if _SOLVE_INK else None)
out.update(_splash_roles(out))
return out
[docs]
def button_accent_text(palette: Optional[dict] = None) -> str:
"""The colour for TEXT drawn in the button accent straight on the page.
``button_accent`` is the same blue on every theme (see
:data:`CONSTANT_ROLES`), and as a fill or an outline it is. As the ink
of an outlined button's caption it is not readable on a light page:
#4A9EFF on the light theme's page is about 2.2:1, and on Glass's
lightest panel it is 3.9:1. So the caption takes the first of a short
list that clears 4.5:1 on every panel the theme has: on a dark theme
the constant blue, then its lighter ``button_accent_hi``; on a light
theme the theme's darker ``accent_hi``, then ``accent_lo``. The ink
itself is the last resort, which clears it on any theme that passes
its own contrast rules.
Derived on request rather than stored as a palette role, so the spaceout
dressing (which re-hues every stored role) needs no entry for it.
:param palette: a palette carrying ``bg``, ``fg`` and the accent roles;
:func:`active_palette` when omitted.
:returns: a hex colour.
"""
if palette is None:
palette = active_palette()
constant = palette.get("button_accent", CONSTANT_ROLES["button_accent"])
try:
light = relative_luminance(palette["bg"]) > relative_luminance(
palette["fg"])
surfaces = [palette[role] for role in PAGE_SURFACES
if role in palette]
order = (("accent_hi", "accent_lo", "fg") if light else
("button_accent", "button_accent_hi", "fg"))
for role in order:
ink = palette.get(role)
if ink and all(contrast_ratio(ink, surface) >= 4.5
for surface in surfaces):
return ink
except (KeyError, TypeError, ValueError):
return constant
return palette.get("fg", constant)
[docs]
def active_palette() -> dict:
"""The palette for the theme that is **on screen right now**.
This is what a widget wants. ``DARK_PALETTE`` is a constant; the
theme is a preference, and it changes while the process is running.
A widget that inlines colours — anything that builds its own
``setStyleSheet`` string, and anything that paints in a
``paintEvent`` — must resolve them through here, per instance, at
construction or paint time.
Screens are rebuilt on a theme change
(``MainWindow._rebuild_startup_page`` for Home, and the stylesheet is
re-applied to everything else), so one call per widget build is
enough; there is no need to cache the result across constructions.
Falls back to dark if preferences cannot be read — headless, no
``QApplication``, a corrupt settings file — because that is what the
app looked like before this function existed.
"""
try:
from .preferences import resolve_effective_theme
return palette_for(resolve_effective_theme())
except Exception:
return palette_for("dark")
[docs]
def page_colour(theme: str = "dark") -> str:
"""The flat colour the page is painted with under ``theme``.
Prefer this over ``palette_for(theme)["bg"]`` anywhere the question
is "what is behind the panels". ``bg`` answers a different question
— see the block above — and on the dark theme it answers it
``#000000``.
Falls back to ``bg`` for a palette that has no ``page``, so an older
or third-party palette dict still resolves rather than raising.
"""
palette = palette_for(theme)
return palette.get("page") or palette["bg"]
[docs]
def active_page_colour() -> str:
""":func:`page_colour` for the theme that is on screen right now.
Falls back to dark, like :func:`active_palette`, and for the same
reason: a backdrop must never be the thing that stops a screen
opening.
"""
try:
from .preferences import resolve_effective_theme
return page_colour(resolve_effective_theme())
except Exception:
return page_colour("dark")
_FROZEN_DARK = MappingProxyType(DARK_PALETTE)
_PALETTE_DEPRECATION = (
"spacr.qt.theme.PALETTE is the DARK palette and nothing updates it, "
"so inlining it renders dark chrome on the light theme (measured: "
"1.08:1 ink-on-panel, i.e. black on black). Use "
"spacr.qt.theme.active_palette() for the theme on screen, or "
"spacr.qt.theme.DARK_PALETTE if you really do mean the dark colours."
)
[docs]
def __getattr__(name: str):
"""Serve the deprecated ``PALETTE`` alias (PEP 562)."""
if name == "PALETTE":
warnings.warn(_PALETTE_DEPRECATION, DeprecationWarning, stacklevel=2)
return _FROZEN_DARK
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
#: What an unreadable colour becomes for a caller that cannot afford to
#: raise. White is the safe answer for paint: it keeps type and scrims
#: visible instead of blanking them.
_UNREADABLE = (255, 255, 255)
def _channels(color: str,
fallback: Optional[Tuple[int, int, int]] = None
) -> Tuple[int, int, int]:
"""Split ``#rgb`` or ``#rrggbb`` into its three 0-255 channels.
Parsing is strict by default, so a palette entry that is not a colour is
reported where someone can fix it. Callers that run inside a paint pass
``fallback`` instead: a swatch that comes out the wrong colour is
cosmetic, an exception raised out of a repaint is not.
"""
parsed = _parse_hex(str(color))
if parsed is not None:
return parsed
if fallback is not None:
return fallback
raise ValueError(f"not a #rrggbb colour: {color!r}")
@lru_cache(maxsize=8192)
def _parse_hex(color: str) -> Optional[Tuple[int, int, int]]:
"""``#rgb`` or ``#rrggbb`` as three 0-255 channels, or ``None``.
Memoised: the palette solvers and every repaint ask about the same few
hundred colours many thousands of times.
"""
text = color.strip().lstrip("#")
if len(text) == 3:
text = "".join(ch * 2 for ch in text)
if len(text) != 6:
return None
try:
return (int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16))
except ValueError:
return None
def _linear(value: int) -> float:
"""Linearise one 8-bit sRGB channel.
:param value: the channel, 0-255.
:returns: its linear-light value in ``[0, 1]``.
"""
c = value / 255.0
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
[docs]
def relative_luminance(color: str) -> float:
"""WCAG relative luminance of a ``#rrggbb`` colour, in [0, 1].
:param color: a ``#rgb`` or ``#rrggbb`` colour string.
"""
return _luminance_of(str(color))
@lru_cache(maxsize=8192)
def _luminance_of(color: str) -> float:
"""Memoised body of :func:`relative_luminance`."""
r, g, b = _channels(color)
return 0.2126 * _linear(r) + 0.7152 * _linear(g) + 0.0722 * _linear(b)
[docs]
def contrast_ratio(a: str, b: str) -> float:
"""WCAG contrast ratio between two colours — 1.0 (same) to 21.0.
:param a: one colour, a ``#rgb`` or ``#rrggbb`` colour string.
:param b: the other colour; the order of ``a`` and ``b`` does not matter.
"""
la, lb = relative_luminance(a), relative_luminance(b)
hi, lo = max(la, lb), min(la, lb)
return (hi + 0.05) / (lo + 0.05)
[docs]
def composite(top: str, alpha: float, under: str = WORST_CASE_UNDER) -> str:
"""Alpha-composite ``top`` at ``alpha`` over ``under``, as hex.
:param top: the upper colour, a ``#rgb`` or ``#rrggbb`` colour string.
:param alpha: opacity of ``top``, clamped to [0, 1].
"""
alpha = max(0.0, min(1.0, float(alpha)))
tr, tg, tb = _channels(top)
ur, ug, ub = _channels(under)
out = tuple(int(round(alpha * t + (1.0 - alpha) * u))
for t, u in ((tr, ur), (tg, ug), (tb, ub)))
return "#%02x%02x%02x" % out
#: FULLY OPAQUE. Tried at 0.94 first, on the reasoning that a solid bar
#: would read as a slab pasted over the backdrop. Seen on a real screen
#: that was still wrong -- "remove the transparency for the bar and it
#: will be perfect" -- because this bar is the frameless window's TITLE
#: bar: the backdrop moving behind its two labels is motion under text
#: the eye is trying to read, and no amount of it is an improvement.
#:
#: Kept as a named constant rather than inlined, because the corner
#: chrome and the bar must agree and this is the single thing they agree
#: on. At 1.0 `css_color` returns plain hex, which is also what the flat
#: themes are required to emit.
MENU_BAR_ALPHA = 1.0
[docs]
def css_color(color: str, alpha: float = 1.0) -> str:
"""Render a colour for QSS — plain hex, or ``rgba()`` when translucent.
:param color: the colour, a ``#rgb`` or ``#rrggbb`` colour string. Returned
unchanged when ``alpha`` is 1 or more.
"""
if alpha >= 1.0:
return color
r, g, b = _channels(color)
return f"rgba({r}, {g}, {b}, {alpha:.3f})"
def _mix_color(a: str, b: str, amount: float) -> str:
"""Mix two hex colours in sRGB space for small material highlights."""
amount = max(0.0, min(1.0, float(amount)))
ac = _channels(a)
bc = _channels(b)
out = tuple(int(round(x * (1.0 - amount) + y * amount))
for x, y in zip(ac, bc))
return "#%02x%02x%02x" % out
[docs]
def glass_material(color: str, alpha: float) -> str:
"""Return a neutral, layered QSS brush that suggests optical depth.
QSS cannot sample and refract pixels behind a widget. A thin bright upper
layer, translucent neutral body, and slightly denser lower edge provide
the stable cross-platform cues of glass without pretending opacity alone
is a material.
:param color: the body colour of the glass, a ``#rgb`` or ``#rrggbb``
colour string.
:param alpha: base opacity of the body, clamped to [0, 1]; the highlight
and lower edge are drawn slightly denser.
"""
alpha = max(0.0, min(1.0, float(alpha)))
highlight = _mix_color(color, "#ffffff", 0.16)
shade = _mix_color(color, "#000000", 0.18)
return (
"qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, "
f"stop: 0 {css_color(highlight, min(1.0, alpha + 0.10))}, "
f"stop: 0.10 {css_color(color, min(1.0, alpha + 0.035))}, "
f"stop: 0.72 {css_color(color, alpha)}, "
f"stop: 1 {css_color(shade, min(1.0, alpha + 0.065))})"
)
[docs]
def effective_surface(theme: str, role: str,
under: Optional[str] = None) -> str:
"""The colour a surface role *actually* presents to the eye.
For opaque themes that is just the palette entry — ``under`` cannot
reach through an alpha of 1.0. For an image theme it is the scrim
composited over ``under``, which defaults to :func:`scrim_under`:
the brightest thing *that theme's* wallpaper pipeline can put behind
a panel. White for Space, whose sky blows its sun out on purpose;
the exposure ceiling for Cell, whose every wallpaper is solved down
to it.
:param theme: the theme name, as passed to :func:`palette_for`.
:param role: the surface role, a key of the theme's palette.
"""
palette = palette_for(theme)
if under is None:
under = scrim_under(theme)
return composite(palette[role], scrim_alpha(theme, role), under)
#: ``(foreground role, surface role, minimum ratio)``.
#:
#: 4.5:1 is AA for body text. 3.0:1 is AA for large text and for
#: non-text UI components (WCAG 1.4.11) — which is the right tier for
#: `fg_dim`, whose only jobs are disabled controls and hint text, both
#: explicitly exempt from 1.4.3, and for the status hues that mostly
#: paint progress-bar chunks.
#: `page` is in the surface list because text really does land on it: a
#: section blurb, a hint under a field, an empty-state line all sit
#: straight on the page between the cards. It is also what caps how far
#: `page` may travel from the panels — see the `page` block above, where
#: `fg_dim` at 3.0:1 is the ceiling the dark value is solved against.
PAGE_SURFACES: Tuple[str, ...] = ("bg", "page", "surface", "surface_alt",
"surface_hi")
CONTRAST_RULES: Tuple[Tuple[str, str, float], ...] = tuple(
[(fg, surf, 4.5)
for fg in ("fg", "fg_muted", "accent")
for surf in PAGE_SURFACES]
+ [("accent", "accent_soft", 4.5),
("accent_hi", "accent_soft", 4.5),
("bg", "accent", 4.5),
("bg", "accent_lo", 4.5),
("bg", "error", 4.5),
("button_accent_ink", "button_accent", 4.5),
("button_accent_ink", "button_accent_hi", 4.5),
("button_accent_ink", "button_accent_lo", 4.5)]
+ [(fg, surf, 3.0)
for fg in ("fg_dim", "success", "warning", "error")
for surf in PAGE_SURFACES]
)
[docs]
def contrast_report(theme: str) -> List[dict]:
"""Measured contrast for every rule in :data:`CONTRAST_RULES`.
Each entry is ``{"fg", "bg", "fg_color", "bg_color", "ratio",
"required", "passes"}``. Surfaces are resolved through
:func:`effective_surface`, so Space is judged on the composited
scrim rather than on a colour the user never actually sees.
:param theme: the theme name, as passed to :func:`palette_for`.
"""
palette = palette_for(theme)
out: List[dict] = []
for fg_role, bg_role, required in CONTRAST_RULES:
fg_color = palette[fg_role]
bg_color = effective_surface(theme, bg_role)
ratio = contrast_ratio(fg_color, bg_color)
out.append({
"fg": fg_role, "bg": bg_role,
"fg_color": fg_color, "bg_color": bg_color,
"ratio": ratio, "required": required,
"passes": ratio >= required,
})
return out
[docs]
def contrast_failures(theme: str) -> List[str]:
"""Human-readable description of every rule ``theme`` fails.
:param theme: the theme name, as passed to :func:`palette_for`.
"""
return _describe(contrast_report(theme))
def _describe(report: List[dict]) -> List[str]:
"""Render the failing contrast rules as readable lines.
:param report: the contrast report.
:returns: one line per FAILING rule, naming both colours, the ratio it
reached and the ratio it needed -- the passes are omitted because a
report of what is fine is a report nobody reads.
"""
return [
f"{r['fg']} ({r['fg_color']}) on {r['bg']} ({r['bg_color']}): "
f"{r['ratio']:.2f}:1 < {r['required']:.1f}:1"
for r in report if not r["passes"]
]
#: The panel roles that rest ON the page. `surface_hi` is excluded: it is
#: the hover colour, and converging toward the page is what it is for.
PAGE_PANEL_ROLES: Tuple[str, ...] = ("surface", "surface_alt")
#: One full palette step. The dark palette's own smallest deliberate
#: surface step is `surface` -> `surface_alt` at 3.75 L*, so this is that,
#: rounded down.
PAGE_MIN_LSTAR = 3.5
#: Roughly double the 1.087:1 that `#000000` against `#0d0e10` produced —
#: which is to say, enough that the ratio agrees with L* rather than
#: merely failing to contradict it.
PAGE_MIN_RATIO = 1.15
#: The page opacity a panel is judged at as well as at 1.0. Deliberately
#: not the default (that is :data:`DEFAULT_PANE_OPACITY`, 1.0): a solve
#: that only holds while panels are fully opaque is a solve that breaks
#: for everyone who moved the slider.
PAGE_FADED_OPACITY = 0.6
#: At 60 % a panel composites 0.6*panel + 0.4*page, so a little over half
#: the separation survives. These are that fraction of the bars above.
PAGE_MIN_LSTAR_FADED = 2.0
PAGE_MIN_RATIO_FADED = 1.08
[docs]
def lightness(color: str) -> float:
"""CIE L* of ``color``, 0 (black) to 100 (white).
Perceptually uniform, which :func:`relative_luminance` is not and
:func:`contrast_ratio` is not: a ratio of 1.09:1 means something very
different between two near-blacks and between two near-whites, and
the page/panel question lives at both ends.
:param color: a ``#rgb`` or ``#rrggbb`` colour string.
"""
y = relative_luminance(color)
return 903.3 * y if y <= 0.008856 else 116.0 * (y ** (1.0 / 3.0)) - 16.0
[docs]
def page_separation_report(theme: str) -> List[dict]:
"""How far each resting panel role sits from the page in ``theme``.
One entry per role in :data:`PAGE_PANEL_ROLES` per opacity in
``(1.0, PAGE_FADED_OPACITY)``, carrying ``{"role", "opacity", "page",
"panel", "ratio", "delta_lstar", "min_ratio", "min_delta_lstar",
"passes"}``.
The faded rows composite the panel over the *page* rather than over
anything else, because the page is what is behind it — that is the
whole subject.
:param theme: the theme name, as passed to :func:`palette_for`.
"""
page = page_colour(theme)
palette = palette_for(theme)
out: List[dict] = []
for role in PAGE_PANEL_ROLES:
for opacity, min_ratio, min_dl in (
(1.0, PAGE_MIN_RATIO, PAGE_MIN_LSTAR),
(PAGE_FADED_OPACITY, PAGE_MIN_RATIO_FADED,
PAGE_MIN_LSTAR_FADED)):
panel = (palette[role] if opacity >= 1.0
else composite(palette[role], opacity, page))
ratio = contrast_ratio(page, panel)
delta = abs(lightness(page) - lightness(panel))
out.append({
"role": role, "opacity": opacity,
"page": page, "panel": panel,
"ratio": ratio, "delta_lstar": delta,
"min_ratio": min_ratio, "min_delta_lstar": min_dl,
"passes": ratio >= min_ratio and delta >= min_dl,
})
return out
[docs]
def page_separation_failures(theme: str) -> List[str]:
"""Human-readable description of every separation ``theme`` fails.
:param theme: the theme name, as passed to :func:`palette_for`.
"""
return [
f"{theme}: page ({r['page']}) vs {r['role']} ({r['panel']}) at "
f"{r['opacity']:.0%}: {r['ratio']:.3f}:1 / {r['delta_lstar']:.2f} L* "
f"< {r['min_ratio']:.2f}:1 / {r['min_delta_lstar']:.2f} L*"
for r in page_separation_report(theme) if not r["passes"]
]
def _bare_image_rules() -> Tuple[Tuple[str, float], ...]:
"""Return the contrast rules that apply straight over the window.
:returns: ``(foreground, required_ratio)`` for every rule whose surface
is the window itself -- the ones a picture theme has to satisfy,
since there is no panel between the text and the image.
"""
return tuple((fg, required)
for fg, surface, required in CONTRAST_RULES
if surface == "bg")
#: ``(foreground role, minimum ratio)`` for every role that can end up
#: painted directly on the window background.
BARE_IMAGE_RULES: Tuple[Tuple[str, float], ...] = _bare_image_rules()
[docs]
def max_background_luma(theme: str) -> float:
"""Brightest a background image may be before ``theme`` fails AA.
Closed form, not a search: for a foreground of relative luminance
``Lf`` and a required ratio ``r``, WCAG allows a background up to
``(Lf + 0.05) / r - 0.05``. The answer is the tightest of those over
:data:`BARE_IMAGE_RULES`, and it is what
:func:`spacr.qt.imagery.solve_dim` expects as its target.
:param theme: the theme name, as passed to :func:`palette_for`.
"""
palette = palette_for(theme)
return min((relative_luminance(palette[role]) + 0.05) / required - 0.05
for role, required in BARE_IMAGE_RULES)
[docs]
def image_contrast_report(theme: str, under: str) -> List[dict]:
"""Measured contrast for every rule, judged over a real image colour.
``under`` is a colour sampled from the wallpaper — in practice the
mean of its brightest text-line-sized region, which is what
:func:`spacr.qt.imagery.brightest_window` returns. Rules naming the
``bg`` surface are judged against it directly, because in an image
theme nothing is painted between the photograph and the text.
Everything else is judged against its scrim composited over it.
:param theme: the theme name, as passed to :func:`palette_for`.
:param under: the wallpaper colour sampled behind the text, a ``#rgb`` or
``#rrggbb`` colour string.
"""
palette = palette_for(theme)
out: List[dict] = []
for fg_role, bg_role, required in CONTRAST_RULES:
fg_color = palette[fg_role]
bg_color = (under if bg_role == "bg"
else effective_surface(theme, bg_role, under))
ratio = contrast_ratio(fg_color, bg_color)
out.append({
"fg": fg_role, "bg": bg_role,
"fg_color": fg_color, "bg_color": bg_color,
"ratio": ratio, "required": required,
"passes": ratio >= required,
})
return out
[docs]
def image_contrast_failures(theme: str, under: str) -> List[str]:
"""Every rule ``theme`` fails over a wallpaper colour ``under``.
:param theme: the theme name, as passed to :func:`palette_for`.
:param under: the wallpaper colour sampled behind the text, a ``#rgb`` or
``#rrggbb`` colour string.
"""
return _describe(image_contrast_report(theme, under))
CONSTANT_ROLES = {
"button_accent": "#4A9EFF",
"button_accent_hi": "#66B2FF",
"button_accent_lo": "#2F80D9",
"button_accent_ink": "#04101c",
}
_SOLVED_SCRIMS[False] = _solve_scrims()
SCRIM_ALPHA.update(_SOLVED_SCRIMS[False])
SPACING = {
"xs": 4,
"sm": 8,
"md": 12,
"lg": 16,
"xl": 24,
"xxl": 32,
}
RADIUS = {
"sm": 4,
"md": 8,
"lg": 12,
"pill": 999,
}
FONT_SIZE = {
"xs": 11,
"small": 12,
"body": 13,
"label": 13,
"header": 15,
"subtitle":17,
"title": 22,
"display": 30,
"hero": 42,
}
#: The only floor a zoomed font size keeps, in px, and it is Qt's rather
#: than a readability choice. ``QFont.setPixelSize`` refuses zero or less
#: ("Pixel size <= 0"), and a style sheet's zero ``font-size`` reaches that
#: call, so a scaled size never drops below one pixel. Nothing larger is
#: imposed: the smallest Zoom is :data:`spacr.qt.preferences.FONT_SCALE_MIN`,
#: and a second floor here would make every setting below it render alike.
#: Applied in :func:`font_px`, the stylesheet's ``F`` table, the late widget
#: QSS in :func:`ensure_widget_qss_applied` and :func:`close_mark_font_px`.
_QT_MIN_FONT_PX = 1
[docs]
def font_px(role_or_px, scale: Optional[float] = None) -> int:
"""Return a font size in px with the user's Zoom preference applied.
The application stylesheet scales :data:`FONT_SIZE` itself (see
:func:`stylesheet`), so anything styled by it already tracks Zoom.
What does *not* is a widget that sets its own sheet — a per-widget
``setStyleSheet`` beats the application sheet whatever the selector
says — or one that paints text with a ``QPainter``. Those surfaces
hard-coded a pixel number and so stayed 13 px at 150 %: the tab
strips, the Home aside, the hover tooltip, the Live/AI toggles.
Route every such number through here instead of writing a literal.
:param role_or_px: a :data:`FONT_SIZE` key (``"body"``, ``"small"``
…) or a raw base pixel size.
:param scale: override the preference — used by :func:`stylesheet`,
which is generating a sheet for a scale that may not be the
saved one yet. ``None`` reads the preference.
:returns: at least 1 px, because Qt refuses a pixel size of zero or
less; no larger floor is imposed.
"""
base = FONT_SIZE.get(role_or_px) if isinstance(role_or_px, str) else None
if base is None:
try:
base = float(role_or_px)
except (TypeError, ValueError):
base = FONT_SIZE["body"]
if scale is None:
try:
from .preferences import get_font_scale
scale = get_font_scale()
except Exception:
scale = 1.0
return max(_QT_MIN_FONT_PX, int(round(float(base) * float(scale))))
TYPOGRAPHY = {
"display": {"size": FONT_SIZE["display"], "weight": 300, "tracking": "-0.4px", "line_height": "1.15"},
"title": {"size": FONT_SIZE["title"], "weight": 500, "tracking": "-0.2px", "line_height": "1.2"},
"subtitle": {"size": FONT_SIZE["subtitle"], "weight": 500, "tracking": "-0.1px", "line_height": "1.25"},
"header": {"size": FONT_SIZE["header"], "weight": 600, "tracking": "0px", "line_height": "1.3"},
"body": {"size": FONT_SIZE["body"], "weight": 400, "tracking": "0px", "line_height": "1.45"},
"small": {"size": FONT_SIZE["small"], "weight": 400, "tracking": "0px", "line_height": "1.4"},
"caption": {"size": FONT_SIZE["xs"], "weight": 500, "tracking": "0.6px", "line_height": "1.4"},
"hero": {"size": FONT_SIZE["hero"], "weight": 200, "tracking": "-0.5px", "line_height": "1.1"},
}
[docs]
def apply_qpalette(app: QApplication, theme: str = "dark", *,
follow_system: bool = False) -> None:
"""Apply the palette to the QApplication so native controls (menu
bars, tooltips, dialogs) match the QSS-styled widgets.
The platform is told the theme's scheme first (see
:func:`hold_the_colour_scheme`), so a theme change the operating system
reports afterwards cannot repaint the roles set here. Disabled text is
the dim ink, so a disabled control reads as disabled on every theme.
:param app: the running QApplication.
:param theme: one of :data:`THEMES`; unknown values fall back to dark.
:param follow_system: release the scheme to the operating system
instead of pinning it; the explicit "Follow system" choice.
"""
hold_the_colour_scheme(app, None if follow_system else theme)
P = palette_for(theme)
p = app.palette()
p.setColor(QPalette.Window, QColor(P["bg"]))
p.setColor(QPalette.WindowText, QColor(P["fg"]))
p.setColor(QPalette.Base, QColor(P["surface"]))
p.setColor(QPalette.AlternateBase, QColor(P["surface_alt"]))
p.setColor(QPalette.ToolTipBase, QColor(P["surface_alt"]))
p.setColor(QPalette.ToolTipText, QColor(P["fg"]))
p.setColor(QPalette.Text, QColor(P["fg"]))
p.setColor(QPalette.Button, QColor(P["surface"]))
p.setColor(QPalette.ButtonText, QColor(P["fg"]))
p.setColor(QPalette.BrightText, QColor(P["error"]))
p.setColor(QPalette.Highlight, QColor(P["accent"]))
p.setColor(QPalette.HighlightedText, QColor(P["bg"]))
p.setColor(QPalette.Link, QColor(P["accent"]))
p.setColor(QPalette.LinkVisited, QColor(P["accent_lo"]))
p.setColor(QPalette.PlaceholderText, QColor(P["fg_dim"]))
p.setColor(QPalette.Mid, QColor(P["border"]))
p.setColor(QPalette.Midlight, QColor(P["border_soft"]))
p.setColor(QPalette.Dark, QColor(P["surface_alt"]))
p.setColor(QPalette.Shadow, QColor("#000000"))
for role in (QPalette.WindowText, QPalette.Text, QPalette.ButtonText):
p.setColor(QPalette.Disabled, role, QColor(P["fg_dim"]))
app.setPalette(p)
[docs]
def scheme_of(theme: str) -> str:
"""``"light"`` or ``"dark"``: which way ``theme`` draws its text.
Read off the palette rather than listed, so a new theme needs no entry
here: a theme whose page is brighter than its ink is a light theme.
:param theme: one of :data:`THEMES`.
:returns: ``"light"`` when the page outshines the ink, else ``"dark"``.
"""
P = palette_for(theme)
return ("light" if relative_luminance(P["bg"])
> relative_luminance(P["fg"]) else "dark")
def _colour_scheme_enum():
"""``Qt.ColorScheme``, or ``None`` on a Qt that predates it (6.5)."""
return getattr(Qt, "ColorScheme", None)
[docs]
def system_colour_scheme(app=None) -> Optional[str]:
"""What the operating system's own light/dark setting is, if Qt knows.
Any scheme spaCR asked for earlier is released first
(``QStyleHints.unsetColorScheme``, Qt 6.8+), so the answer is the
platform's -- macOS appearance, the Windows app mode, or the GTK/KDE
preference on Linux -- and not an echo of what spaCR requested. Only
the ``"system"`` theme calls this.
:param app: the running application; ``QApplication.instance()`` when
omitted.
:returns: ``"dark"``, ``"light"``, or ``None`` when Qt cannot tell
(the offscreen platform, a desktop with no preference, Qt < 6.5).
"""
app = app or QApplication.instance()
scheme = _colour_scheme_enum()
if app is None or scheme is None:
return None
try:
hints = app.styleHints()
if hasattr(hints, "unsetColorScheme"):
hints.unsetColorScheme()
current = hints.colorScheme()
except Exception: # noqa: BLE001
LOG.debug("could not read the system colour scheme", exc_info=True)
return None
if current == scheme.Dark:
return "dark"
if current == scheme.Light:
return "light"
return None
[docs]
def hold_the_colour_scheme(app, theme: Optional[str]) -> bool:
"""Tell the platform which scheme spaCR draws in, so it cannot differ.
Qt 6.8 added ``QStyleHints.setColorScheme``. Without it a Mac in light
appearance draws spaCR's title bar, native menus and file dialogs light
around a dark window, and Windows does the same with its title bar; on
Linux the GTK and KDE platform themes feed their own palette to the
style. Setting it pins those to the theme in force. ``None`` releases
the request, which is what the explicit "Follow system" choice wants.
A no-op returning ``False`` on a Qt older than 6.8, where the palette
and stylesheet (and the Fusion style :func:`use_a_style_that_honours_the_palette`
installs) still carry the colours.
:param app: the running application.
:param theme: a theme from :data:`THEMES`, or ``None`` to follow the
system again.
:returns: ``True`` when the request reached Qt.
"""
scheme = _colour_scheme_enum()
if app is None or scheme is None:
return False
try:
hints = app.styleHints()
if not hasattr(hints, "setColorScheme"):
return False
if theme is None:
if hasattr(hints, "unsetColorScheme"):
hints.unsetColorScheme()
else:
hints.setColorScheme(scheme.Unknown)
return True
wanted = (scheme.Light if scheme_of(theme) == "light"
else scheme.Dark)
if hints.colorScheme() != wanted:
hints.setColorScheme(wanted)
return True
except Exception: # noqa: BLE001
LOG.debug("could not hold the colour scheme", exc_info=True)
return False
[docs]
def use_a_style_that_honours_the_palette(app=None, environ=None) -> str:
"""Put the application on Fusion unless somebody asked for a style.
spaCR's stylesheet is written against Fusion, which draws every control
from the palette. The native macOS and Windows styles draw some of them
-- combo boxes, spin-box buttons, scroll bars, the parts of a control no
rule reaches -- from the operating system's own light or dark setting,
which is how a dark spaCR came out with light fields on a light Mac.
Left alone when ``QT_STYLE_OVERRIDE`` is set: that is somebody choosing
a style on purpose. (``launch`` hands Qt only the program name, so a
``-style`` argument never reaches Qt and needs no exception here.)
:param app: the application; ``QApplication.instance()`` when omitted.
:param environ: environment to consult; ``os.environ`` when omitted.
:returns: the name of the style in force afterwards, lower case.
"""
import os
from PySide6.QtWidgets import QStyleFactory
app = app or QApplication.instance()
if app is None:
return ""
environ = os.environ if environ is None else environ
current = str(app.style().name() if app.style() else "").lower()
asked = bool(str(environ.get("QT_STYLE_OVERRIDE", "")).strip())
if asked or current == "fusion":
return current
fusion = QStyleFactory.create("Fusion")
if fusion is None:
return current
app.setStyle(fusion)
LOG.info("style %r replaced by Fusion so the theme's palette holds",
current)
return "fusion"
#: Dynamic property that marks a widget as a *page surface*: something
#: that lays other widgets out but must not paint anything itself.
TRANSPARENT_PROPERTY = "spacrTransparent"
#: Dynamic property that marks a widget as *being* the page surface rather
#: than sitting on one — the exact opposite of :data:`TRANSPARENT_PROPERTY`,
#: and the one opt-out from :func:`clear_container_surfaces`. See
#: :func:`mark_surface`, which is how a screen sets it.
SURFACE_PROPERTY = "spacrSurface"
[docs]
def mark_surface(*widgets) -> None:
"""Declare that ``widgets`` ARE the page surface, not passengers on one.
:func:`clear_container_surfaces` tags every ``QAbstractScrollArea`` by
type, and ``QAbstractItemView`` and ``QPlainTextEdit`` are both one. So
the shipped ``QTableView/QTreeView {{ background-color: surface_alt }}``
rule never landed on any view in the application: the attribute selector
for :data:`TRANSPARENT_PROPERTY` outranks a bare type selector, and the
view painted nothing at all.
Where a view sits on a tab pane or inside a card that is right, and it is
right by accident — the container behind supplies the surface and the view
shows it through. Where a view sits straight on the page there is nothing
behind it, and the backdrop arrives untouched: a measured 1.000
transmission, which over a near-black window colour reads as a black box
with the text floating on it.
The sweep cannot be narrowed to exact types. Doing that flips
**every** view in the application at once, and the ones already sitting on
a pane would then stack two translucent greys and read about 0.49 — a
shade no position of the page-opacity slider can produce. Nor can a type
test make the distinction: Hit List's ``QTreeWidget`` is the page and
Control Chart's ``QListWidget`` is a passenger, and the pair after them is
the other way round. Only the screen that built the layout knows which it
is, so the screen is asked, once, per view.
Opt-**in** rather than opt-out on purpose. Today every view in the
application is swept, so opting in changes nothing except where a screen
says so, and a view nobody has looked at keeps the behaviour it was
written against.
Two screens said this before the mechanism existed, by giving the view an
object name and registering a whole QSS block for it — Model Compare's
result tables, Model Zoo's listing and provenance box. An ID selector
outranks the transparent tag the same way. This is that without the block:
one call, and either the shipped table rule or the
``*[spacrSurface="true"]`` rule supplies the fill at the user's page
opacity. The second is not redundant: nothing in the sheet covers a bare
``QListWidget``, which would otherwise fall through to the blanket
``QWidget`` rule and paint the WINDOW colour, which is not a surface.
Safe to call before or after the sweep, and safe to call twice: the
transparent tag is cleared as well as the surface tag set, and the style
is re-polished so a visible widget changes immediately.
One Qt rule has to be paid on the way. A **subclass** of ``QWidget``
ignores a QSS background entirely unless ``WA_StyledBackground`` is set,
which is why Power's caveat panel still measured the backdrop untouched
with a matching rule sitting in the sheet. The attribute is set here for
those, and deliberately NOT for a ``QAbstractScrollArea``: a view already
paints its background through its viewport, and a second styled fill on
top of that is the two-surfaces-stacked fault again. Measured both ways
rather than reasoned about.
"""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QAbstractScrollArea
for widget in widgets:
if widget is None:
continue
targets = [widget]
if isinstance(widget, QAbstractScrollArea):
targets.append(widget.viewport())
else:
widget.setAttribute(Qt.WA_StyledBackground, True)
for target in targets:
if target is None:
continue
target.setProperty(SURFACE_PROPERTY, True)
target.setProperty(TRANSPARENT_PROPERTY, False)
style = target.style()
if style is not None:
style.unpolish(target)
style.polish(target)
[docs]
def is_surface(widget) -> bool:
"""Whether ``widget`` was declared a page surface by :func:`mark_surface`.
:param widget: the widget to test; None gives False.
"""
if widget is None:
return False
return bool(widget.property(SURFACE_PROPERTY))
[docs]
def clear_container_surfaces(root) -> int:
"""Tag every layout container under ``root`` so it paints nothing.
Most spaCR screens are plain ``QWidget`` trees. A ``QWidget`` with no QSS
rule of its own inherits the blanket ``QWidget {{ background-color: bg }}``
and paints the WINDOW colour — which is not a surface, so no value of the
page-opacity preference can reach it. That is why a screen could sit as a
black slab over the animated background no matter what the slider said.
The rule, and it is a heuristic worth stating plainly:
* an **anonymous** ``QWidget`` (no ``objectName``) is scaffolding — it
exists to hold a layout, so it should show whatever is behind it;
* a **named** widget is something the designer styled on purpose — a
``Card``, a ``Section``, a ``ConsoleBox`` — and keeps its fill, at the
page opacity.
Scroll areas, their viewports and splitters are always containers whatever
they are called, so they are tagged by type — **unless** the screen has
declared one a surface with :func:`mark_surface`. That is the one opt-out,
and it exists because the type test cannot tell a table that sits ON a
pane from a table that IS the page. See :func:`mark_surface`.
:param root: the screen (or any subtree) to sweep.
:returns: how many widgets were tagged, which is what a test asserts on.
"""
from PySide6.QtWidgets import (QAbstractScrollArea, QSplitter,
QStackedWidget, QWidget)
targets = []
for area in root.findChildren(QAbstractScrollArea):
if is_surface(area):
continue
targets.append(area)
viewport = area.viewport()
if viewport is not None:
targets.append(viewport)
targets.extend(root.findChildren(QSplitter))
targets.extend(root.findChildren(QStackedWidget))
for widget in root.findChildren(QWidget):
if type(widget) is QWidget and not widget.objectName():
targets.append(widget)
targets = [w for w in targets if not is_surface(w)]
make_transparent(*targets)
return len(targets)
[docs]
def make_transparent(*widgets) -> None:
"""Stop ``widgets`` painting a background of their own.
A backdrop — the theme's wallpaper, or the DNA rain on the
sequencing screen — is behind the *page*, and in the opaque themes
every container between it and the eye is an opaque ``bg`` by
virtue of the blanket ``QWidget`` rule. One container is enough to
bury it: a screen's header widget, its splitter, a scroll area and
that scroll area's viewport are each a QWidget, and each one used to
paint solid black over the animation the screen had just installed.
Tag the layout containers with this and the backdrop reaches the
eye; leave the cards, panels and inputs alone and they stay the
readable surface on top of it. Safe to call on a widget that is
already visible — the style is re-polished so the change takes
effect immediately rather than at the next theme switch.
A ``QScrollArea``'s ``viewport()`` is tagged automatically along
with it: they are two widgets, the viewport is the one that
actually paints, and forgetting it is the obvious way to get this
wrong.
"""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QAbstractScrollArea
for widget in widgets:
if widget is None:
continue
targets = [widget]
if isinstance(widget, QAbstractScrollArea):
targets.append(widget.viewport())
for target in targets:
if target is None:
continue
if (target.property(TRANSPARENT_PROPERTY) is True
and target.testAttribute(Qt.WA_StyleSheetTarget)):
continue
target.setProperty(TRANSPARENT_PROPERTY, True)
style = target.style()
if style is not None:
style.unpolish(target)
style.polish(target)
[docs]
def panel_qcolor(role: str = "surface",
theme: Optional[str] = None,
opacity: Optional[float] = None) -> QColor:
""":func:`pane_surface` as a ``QColor``, alpha included.
The QSS accessor is no use to a widget that draws itself: a
custom-painted canvas has no stylesheet to put ``rgba(...)`` in, and
the obvious ``QColor(active_palette()["surface"])`` it reaches for
instead is **raw hex** — fully opaque, whatever the page-opacity
preference says. That is how a screen ends up with one flat black
rectangle in the middle of a page of translucent panels.
:param role: palette key, normally ``surface``/``surface_alt``.
:param theme: theme name; ``None`` resolves the effective one.
:param opacity: 0..1 override; ``None`` reads the preference.
"""
if theme is None:
try:
from .preferences import resolve_effective_theme
theme = resolve_effective_theme()
except Exception:
theme = "dark"
if opacity is None:
try:
from .preferences import get_pane_opacity
opacity = get_pane_opacity()
except Exception:
opacity = None
base = palette_for(theme)
colour = QColor(base.get(SCRIM_ROLES.get(role, role),
base["surface_alt"]))
colour.setAlphaF(max(0.0, min(1.0, panel_alpha(theme, role, opacity))))
return colour
[docs]
def paint_panel(painter, widget, *, role: str = "surface",
radius: Optional[int] = None,
border: bool = True,
inset: float = 0.0,
theme: Optional[str] = None,
opacity: Optional[float] = None) -> None:
"""Draw a rounded, translucent panel filling ``widget``.
The ``paintEvent`` counterpart of the QSS panel rules, for the
regions QSS cannot reach. Call it first in a ``paintEvent``, in
place of ``painter.fillRect(self.rect(), QColor(palette["surface"]))``
— that call is opaque by construction and is what makes a
custom-painted canvas read as a bare dark hole punched through the
page.
Composites rather than replaces: the widget must be
``WA_TranslucentBackground`` or otherwise unfilled for the backdrop
to reach this, which is what tagging the parents with
:func:`make_transparent` arranges.
:param painter: an active ``QPainter`` on ``widget``.
:param widget: the widget being painted; its ``rect()`` is the panel.
:param role: palette key for the fill.
:param radius: corner radius in px; ``None`` uses ``RADIUS["md"]``.
:param border: draw the theme's soft hairline around the panel.
:param inset: shrink the panel by this many px on every side, so a
1 px border lands inside the widget instead of being clipped.
"""
from PySide6.QtCore import QRectF, Qt
from PySide6.QtGui import QPainter, QPen
if theme is None:
try:
from .preferences import resolve_effective_theme
theme = resolve_effective_theme()
except Exception:
theme = "dark"
corner = float(RADIUS["md"] if radius is None else radius)
rect = QRectF(widget.rect()).adjusted(inset, inset, -inset, -inset)
painter.save()
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setPen(Qt.NoPen)
painter.setBrush(panel_qcolor(role, theme, opacity))
painter.drawRoundedRect(rect, corner, corner)
if border:
base = palette_for(theme)
pen = QPen(QColor(base["border_soft"]))
pen.setWidthF(1.0)
painter.setPen(pen)
painter.setBrush(QColor(0, 0, 0, 0))
painter.drawRoundedRect(rect.adjusted(0.5, 0.5, -0.5, -0.5),
corner, corner)
painter.restore()
def _qss_url(path) -> str:
"""Quote a filesystem path for a QSS ``url(...)``.
QSS wants forward slashes on every platform — a Windows backslash
path silently fails to load and you get no background at all.
"""
text = str(path).replace("\\", "/").replace('"', '\\"')
return f'url("{text}")'
def _the_backdrop_is_on() -> bool:
"""Whether an animated backdrop is running behind the screens.
Read here rather than passed in, because `stylesheet()` has a dozen
callers and none of them knows about the backdrop. Guarded: a build
without preferences -- a test composing a sheet, a docs run -- gets the
opaque window block, which is what it had before this existed.
"""
try:
from .preferences import get_ambient_enabled
return bool(get_ambient_enabled())
except Exception: # noqa: BLE001
return False
def _window_block(theme: str, P: dict, background, body_px: int,
backdrop: bool = False) -> str:
"""The base + top-level-window rules, which image themes rewrite.
An image theme needs three things the opaque themes do not: a
background image on the window, ``QWidget`` transparent so the image
is not covered by every child, and each top-level window type
explicitly re-opaqued so a stray plain ``QWidget`` window does not
render as a hole.
AN ANIMATED BACKDROP NEEDS THE SAME THREE, and that is what
``backdrop`` is for. The window's own backdrop sits behind the screen
stack, so every plain ``QWidget`` container painting an opaque ``bg``
over it hides it -- and on the dark theme ``bg`` is ``#000000``.
Measured from X on this machine, the home screen with the one-backdrop
dedup switched back on:
theme without this with it
dark 3.0 % chromatic, 58.3 % chromatic,
20.4 % pure black 0.0 % pure black
cell 93.5 %, 0.2 % unchanged, it is an image theme
glass 67.3 %, 0.0 % unchanged, likewise
The picture is the difference between a theme and a black window. The
window still paints the theme's own ground rather than an image, so
nothing here depends on a cached master existing.
WHAT THE BLANKET RULE ACTUALLY SAYS NOW, because a great many comments
in `tests/` state the pre-2026-09-11 half of it in the present tense::
theme backdrop off backdrop on
dark #000000 transparent
light #fafafa transparent
space #04060d transparent
cell transparent transparent
glass transparent transparent
and the backdrop is ON by default.
SO "REMOVE A WIDGET'S RULE AND IT FALLS THROUGH TO AN OPAQUE `bg`"
IS NO LONGER A WAY TO STAGE A BLACK SLAB. It was, for every opaque
theme, and a guard in `tests/qt/test_settings_column_panel.py` was
written on it -- it stopped reproducing the fault the day this
branch changed, passed for as long as nobody looked, and then failed
on a defect it could no longer produce. A test that wants an opaque
container has to PAINT ONE, which does not depend on any other rule
in the sheet. Swept 2026-09-11: that guard is the only one that had
the shape, and it is repaired.
"""
if theme not in IMAGE_THEMES and not backdrop:
return f"""QWidget {{
background-color: {P["bg"]};
color: {P["fg"]};
font-family: "Open Sans", "Segoe UI", "Helvetica Neue", sans-serif;
font-size: {body_px}px;
outline: none;
}}
QMainWindow, QDialog {{
background-color: {P["bg"]};
}}"""
if theme not in IMAGE_THEMES:
sky = f'background-color: {P["bg"]};'
elif background is not None:
sky = (f'background-color: {P["bg"]};\n'
f' background-image: {_qss_url(background)};\n'
' background-position: center center;\n'
' background-repeat: no-repeat;')
elif theme == "glass":
sky = (
"background-color: qradialgradient(\n"
" cx: 0.18, cy: 0.12, radius: 1.08,\n"
" fx: 0.14, fy: 0.08,\n"
" stop: 0 #454950, stop: 0.18 #292d33,\n"
" stop: 0.52 #16191e, stop: 0.82 #0e1115,\n"
" stop: 1 #080a0d);")
else:
sky = ("background-color: qlineargradient(\n"
" x1: 0, y1: 0, x2: 1, y2: 1,\n"
f' stop: 0 {P["surface"]}, stop: 0.55 {P["bg"]},\n'
f' stop: 1 {P["accent_soft"]});')
return f"""/* Image theme: the window paints the picture and every child
is transparent by default, so panels are the only opaque things and
the imagery shows through the gaps. */
QWidget {{
background-color: transparent;
color: {P["fg"]};
font-family: "Open Sans", "Segoe UI", "Helvetica Neue", sans-serif;
font-size: {body_px}px;
outline: none;
}}
QMainWindow, QDialog {{
{sky}
}}
/* Popups are separate top-level windows: they must stay opaque or a
compositor-less desktop shows through them. */
QMenu, QToolTip, QMessageBox, QComboBox QAbstractItemView {{
background-color: {P["surface_alt"]};
}}"""
def _glass_material_layer(base: dict,
surface_opacity: Optional[float]) -> str:
"""Final QSS overrides that turn neutral transparency into a material.
Kept as a last layer so every existing selector still has a conservative
fallback. Only Glass receives these rules; Dark, Light, Space, and Cell
remain byte-for-byte on their existing paths.
"""
surface = glass_material(
base["surface"], panel_alpha("glass", "surface", surface_opacity))
alt = glass_material(
base["surface_alt"],
panel_alpha("glass", "surface_alt", surface_opacity))
high = glass_material(
base["surface_hi"],
panel_alpha("glass", "surface_hi", surface_opacity))
tile = glass_material(
base["surface"], panel_alpha("glass", "tile", surface_opacity))
rim = css_color("#ffffff", 0.27)
rim_soft = css_color("#ffffff", 0.16)
return f"""
/* -----------------------------------------------------------------
* Glass material layer
*
* Neutral translucent body + a brighter upper stop suggest lensing;
* white rims provide a specular silhouette; larger concentric radii
* make controls float. Accent colour remains reserved for actions.
* ----------------------------------------------------------------- */
QFrame#Card, QFrame#ConsoleBox {{
background: {alt};
border: 1px solid {rim};
border-radius: 14px;
}}
/* The masthead is type on the page, not a card — no glass body, no rim. */
QFrame#Hero {{
background: transparent;
border: none;
}}
QFrame#SectionCard {{
background: {surface};
border: 1px solid {rim_soft};
border-radius: 14px;
}}
QFrame#ConsoleTopicBar {{
background: {high};
border-top: 1px solid {rim_soft};
border-bottom: 1px solid {css_color("#000000", 0.24)};
}}
QPlainTextEdit#ConsoleStdoutBlock,
QPlainTextEdit#ConsoleStdoutBlockError {{
background: transparent;
}}
QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox,
QPlainTextEdit, QTextEdit {{
background: {alt};
border: 1px solid {rim_soft};
border-radius: 10px;
}}
QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus,
QComboBox:focus, QPlainTextEdit:focus, QTextEdit:focus {{
background: {high};
border: 1px solid {base["accent"]};
}}
QPlainTextEdit#ConsoleChatInput, QTextEdit#ConsoleChatInput {{
background: {alt};
border: 1px solid {rim};
border-radius: 14px;
}}
QPushButton {{
background: {alt};
border: 1px solid {rim_soft};
border-radius: 10px;
}}
QPushButton:hover {{
background: {high};
border: 1px solid {rim};
}}
QPushButton#Tile, QPushButton#HTile {{
background: {tile};
border: 1px solid {rim};
border-radius: 16px;
}}
/* Module launchers sit directly on the Home pane. A resting glass rim made
each row read as a ruled table; only an interactive state earns an edge. */
QPushButton#AppTile {{
background: {tile};
border: none;
border-radius: 16px;
}}
QPushButton#AppTile:focus {{
border: 1px solid {base["accent"]};
}}
QGroupBox {{
border: 1px solid {rim_soft};
border-radius: 12px;
}}
"""
#: registration name → ``fn(palette, opacity) -> str``. Insertion-ordered:
#: QSS is order-sensitive between rules of equal specificity, so "first
#: registered wins ties" is the contract, and it is the import order of
#: the widgets themselves.
_WIDGET_QSS: Dict[str, object] = {}
#: Every module that registers a widget QSS block **at import time**.
#:
#: This is the exhaustive/static stylesheet inventory. The public
#: :func:`stylesheet` default imports it so documentation, screenshots and
#: callers that request one complete sheet retain that contract. Production
#: startup skips the imports: when a screen is opened, its root receives the
#: registered blocks that are absent from the application sheet before that
#: root is shown. Scoping the late rules to the new screen avoids asking Qt
#: to re-polish every widget already alive merely because one module arrived.
#: Order follows registration order, with later rules winning ties.
WIDGET_QSS_MODULES: Tuple[str, ...] = (
"spacr.qt.settings_search",
"spacr.qt.screens.annotate",
"spacr.qt.screens.app_screen",
"spacr.qt.screens.settings_model",
"spacr.qt.shortcuts",
"spacr.qt.recipes",
"spacr.qt.comparison_grid",
"spacr.qt.counting_tool",
"spacr.qt.curation_tool",
"spacr.qt.layer_viewer",
"spacr.qt.ortho_view",
"spacr.qt.roi_tool",
"spacr.qt.screens.classify",
"spacr.qt.screens.classifier_evaluation",
"spacr.qt.screens.control_chart",
"spacr.qt.screens.data_manager",
"spacr.qt.screens.experiment_design",
"spacr.qt.screens.gate_editor",
"spacr.qt.screens.hit_list",
"spacr.qt.screens.image_scatter",
"spacr.qt.screens.make_masks",
"spacr.qt.screens.map_barcodes",
"spacr.qt.screens.methods_export",
"spacr.qt.screens.model_compare",
"spacr.qt.screens.model_zoo",
"spacr.qt.screens.outliers",
"spacr.qt.screens.pipeline_graph",
"spacr.qt.screens.power",
"spacr.qt.screens.profiler",
"spacr.qt.screens.qc_dashboard",
"spacr.qt.prerun",
"spacr.qt.screens.run_compare",
"spacr.qt.screens.run_history",
"spacr.qt.widgets.gate_editor",
"spacr.qt.widgets.table_chip",
"spacr.qt.widgets.gate_console",
"spacr.qt.widgets.class_editor",
"spacr.qt.widgets.condition_annotation_dialog",
"spacr.qt.widgets.field_fade",
"spacr.qt.widgets.formula_editor",
"spacr.qt.widgets.graph_builder",
"spacr.qt.widgets.pca_view",
"spacr.qt.widgets.pivot_builder",
)
_QSS_REGISTRARS_LOADED = False
#: The marker :func:`registered_widget_qss` writes above every block. It is
#: what tells a live stylesheet apart from one generated before a screen
#: module was imported.
_WIDGET_QSS_MARKER = "/* --- registered widget QSS: {name} --- */"
_LOCAL_WIDGET_QSS_START = "/* --- local registered widget QSS: start --- */"
_LOCAL_WIDGET_QSS_END = "/* --- local registered widget QSS: end --- */"
_LOCAL_WIDGET_QSS_ATTRIBUTE = "_spacr_local_widget_qss_suffix"
_WIDGET_QSS_CONTEXT_ATTRIBUTE = "_spacr_widget_qss_context"
[docs]
def set_widget_qss_context(app, theme: str, font_scale: float,
surface_opacity: Optional[float]) -> None:
"""Record the exact live preference inputs for late screen blocks.
:param app: the application object the context is stored on; None does
nothing.
:param theme: the active theme name.
:param font_scale: the active font scale, stored as a float.
:param surface_opacity: the page-opacity preference, or None for the
theme's designed scrim.
"""
if app is not None:
setattr(app, _WIDGET_QSS_CONTEXT_ATTRIBUTE,
(str(theme), float(font_scale), surface_opacity))
def _live_widget_qss_context(app) -> Tuple[str, float, Optional[float]]:
"""Return the preference inputs used by the live application sheet."""
context = getattr(app, _WIDGET_QSS_CONTEXT_ATTRIBUTE, None)
if (isinstance(context, tuple) and len(context) == 3):
return context
try:
from .preferences import (
get_font_scale,
get_pane_opacity,
resolve_effective_theme,
)
return (resolve_effective_theme(), get_font_scale(),
get_pane_opacity())
except Exception:
return "dark", 1.0, None
def _widget_qss_palette(theme: str, font_scale: float,
surface_opacity: Optional[float]) -> dict:
"""Build the callback palette shared by global and screen-local QSS."""
base = palette_for(theme)
palette = dict(base)
for role in ("surface", "surface_alt", "surface_hi"):
palette[role] = css_color(
base[role], panel_alpha(theme, role, surface_opacity))
palette["theme"] = theme
palette["font_scale"] = font_scale
return palette
#: The sheet every top-level window is carrying, parked on the QApplication
#: so a window born later can find it.
_WINDOW_SHEET_ATTRIBUTE = "_spacr_window_stylesheet"
#: Bumped every time that sheet is replaced. A window carries the serial it
#: was last sheeted with, which is what makes the filter below idempotent --
#: a `Polish` and a `Show` for the same window do the work once.
#: QT DYNAMIC PROPERTIES, NOT PYTHON ATTRIBUTES, for the reason
#: `_PASS_STAMP` and `_SHEET_TARGET` already are: a Python attribute lives on
#: the WRAPPER and the stylesheet lives on the C++ object, and PySide6 is free
#: to collect and recreate a wrapper for an object C++ owns. The two then
#: disagree -- and a widget whose bookkeeping was lost while its sheet
#: survived looks UNSHEETED to us, so it is never updated on a theme change
#: and keeps wearing the previous theme.
#:
#: Found as one QMenu out of 28 sheeted widgets that `_forget_window_stylesheets`
#: could not clear: full sheet, no serial. The per-APPLICATION serial below is
#: still an attribute, because a QApplication's wrapper is not collected.
_WINDOW_SHEET_SERIAL = "_spacr_window_stylesheet_serial"
#: A digest of the sheet text this widget was last given. THE SERIAL ALONE
#: IS NOT ENOUGH: it records that we sheeted a widget, not that the widget
#: still carries what we gave it. `AppScreen._sync_page_palette` calls
#: `setStyleSheet("")` on its own root -- correct when the APPLICATION
#: carried the sheet and the screen's own was a local suffix, and fatal now
#: that the screen carries the whole thing. Measured: a module opened after
#: the last theme change had the serial stamped, an empty stylesheet, and a
#: probe under it resolving to `#000000` on the dark theme.
_WINDOW_SHEET_DIGEST = "_spacr_window_stylesheet_digest"
#: The length and digest of the GLOBAL part of what we last applied, so an
#: APPEND can be told from a REPLACEMENT. Without this pair the sheet grows
#: by a whole copy of itself at every theme change -- measured, a widget on
#: five successive themes wore 1.01x, 2.01x, 3.01x, 4.01x and 5.00x the
#: global sheet, 359 KB where 72 KB was correct.
_WINDOW_SHEET_BASE_LEN = "_spacr_window_stylesheet_base_len"
_WINDOW_SHEET_BASE_DIGEST = "_spacr_window_stylesheet_base_digest"
#: Whatever stylesheet a window had of its OWN before the application sheet
#: was put on it. Kept because a parentless widget IS a window -- Qt says so
#: -- and several of them set their own rules: a 26px field in a render
#: test, a card, a status label. Under `QApplication.setStyleSheet` those
#: rules were MERGED with the global ones by Qt; replacing the widget's
#: sheet outright would throw them away, which is a regression and not a
#: test artefact.
_WINDOW_OWN_SHEET = "_spacr_window_own_stylesheet"
#: Set on a widget whose sheet is owed and not yet paid: its own rule was
#: given while it was still being built, so the sheet waits for the widget's
#: first `Polish` as a page or its first `Show`. See
#: :func:`_the_sheet_can_wait_for_the_show`. Cleared by
#: :func:`_sheet_one_window` the moment the sheet lands.
_WINDOW_SHEET_WAITS = "_spacr_window_stylesheet_waits_for_its_show"
#: The one filter instance, kept off the QApplication's children so it is
#: not collected.
_WINDOW_SHEET_FILTER = None
#: The two event types that mean "this widget is about to need its style",
#: as a set of the enum members rather than a tuple rebuilt per event. The
#: filter is on the QApplication, so this membership test is the FIRST
#: thing every event in the process pays for: 1,055,402 of them in one
#: registry sweep, measured. As `(QEvent.Polish, QEvent.Show)` -- two
#: global lookups, two attribute lookups and a tuple build, every time --
#: that line alone cost 475 ms of the sweep.
_SHEETING_MOMENTS = frozenset({QEvent.Polish, QEvent.Show})
def _a_window_for_want_of_a_parent(widget) -> bool:
"""True when ``widget`` is mid-construction, not a window that appears.
QT CALLS A PARENTLESS WIDGET A WINDOW. `QWidget::isWindow` is
`window_flags & Qt::Window`, and Qt forces that flag on when the parent
is null -- so a `QSpinBox()` built on its own line, one statement
before `layout.addWidget(it)`, is a window for as long as it takes to
reach the next statement. Qt polishes it in that window, the
application-wide filter sees the `Polish`, and the widget is handed all
~49 KB of the composed sheet for a window it is about to stop being.
THE NUMBER, from one offscreen registry sweep of 45 modules:
the whole filter 10,518 ms
widgets that were windows only for want of a parent 3,817 ms
AND OVER HALF OF THAT IS ONE SCREEN, SHEETED THREE TIMES. A module
screen is built parentless and only then added to the stack, so it
collects `Polish` events all through its own construction:
AppScreen, 1st sheeting (still empty) 11 x 7 ms = 77 ms
AppScreen, 2nd sheeting (now populated) 11 x 255 ms = 2,805 ms
AppScreen, 3rd sheeting (shown, in stack) 10 x 291 ms = 2,911 ms
The third is the one that matters and the only one this leaves. The
sheet is byte-for-byte the same all three times; what makes the repeat
cost 255 ms rather than 7 ms is that the screen has grown ~1,500
widgets in between and Qt repolishes every one of them.
WHY SKIPPING IT IS SAFE, and it is the design's own answer rather than
a new one. `MainWindow._a_page_joined_the_stack` says it outright --
"the pages that are not showing are marked instead, and sheeted on
their own `showEvent` before they are painted". A page is marked with
:func:`mark_as_a_sheet_target` BEFORE it joins the stack, so the
`Show` that raises it goes down the sheet-target branch of the filter
and the page is sheeted there. A widget that is parented instead is a
descendant of a window that already carries the sheet, and QSS reaches
descendants -- that is how every one of these widgets was styled when
the sheet lived on the QApplication. A widget that is neither shown nor
parented is not rendered by anything and does not need a stylesheet.
WHAT THIS DELIBERATELY DOES NOT CATCH is every window that is really
about to appear:
* a menu, a tooltip, a popup -- `windowType()` is `Qt.Popup` or
`Qt.ToolTip`, not `Qt.Window`, so the test is False and they are
sheeted at `Polish` exactly as before. MENUS AND TOOLTIPS STAY
COVERED; they were the part 380 could not test and they are
untouched here.
* a `QDialog` -- `windowType()` is `Qt.Dialog`. Same.
* a real top-level window being shown -- by the time Qt polishes it
the native window exists, so `WA_WState_Created` is set. Measured:
`MainWindow`'s `Polish` arrives with it already True.
* anything already visible.
IT ALSO CHANGES WHAT THE APP PAINTS, and that is not a side effect to
leave unwritten. A duplicate copy of the sheet on an intermediate
widget does not only cost time: Qt resolves QSS from the NEAREST
stylesheet first, so a generic rule in a copy one layout deep beats a
more specific rule on the screen root. Those copies carry the GLOBAL
sheet ONLY -- the blocks :func:`register_widget_qss` appends live on
the screen root alone -- so every widget one of those blocks names was
painted the plain `fg` instead. Measured on the real window with mask,
measure and regression open, light theme, one process each way: 31
widgets change colour when the copies go, every one of them from `fg`
to the colour its own block asks for -- `QLabel#ChainingStale`
#0d0e10 -> #8f4e00 (`warning`), `#ChainingSource` -> #4b5460
(`fg_muted`), `#ChainingFix`, `#ChainingPinned` and
`#SettingsSearchCount` -> #68707e (`fg_dim`) -- and 42 widgets move,
seven of them visible, because a label sized by its own block is a
different height: the settings scroll area gains the 3 px its search
bar gives back. NO TEST HOLDS ANY OF THOSE COLOURS, which is why
nothing went red either way.
AND A WIDGET RENDERED WITHOUT EVER BEING SHOWN IS NO LONGER SHEETED.
`QWidget.grab()` polishes and never shows, so the skip holds and the
widget carries nothing. EVERY `.grab()` IN spaCR WAS CHECKED against
the four clauses below rather than the one that came to mind:
`figures.scene.build_scene` the only parentless, unshown grab --
a `GraphicsLayoutWidget` grabbed to force the paint that lets
the second `activate()` measure an axis. Safe twice over: its
pixels are byte-identical either way (checked on a two-axes
figure, same SHA-256) because pyqtgraph paints from its own
pens and not from QSS, and more decisively THE GRAB'S RESULT IS
DISCARDED -- it is called for its side effect, never assigned.
`resources/home/.../render.py` calls `page.show()` BEFORE it grabs,
so the widget fails both the visibility and the
`WA_WState_Created` clause. The home-screen version art renders
exactly as before; this is the one that would have been
expensive to get wrong.
`gate_editor`, `app_screen`, `setup_dialog`, `tutorial.engine`
all grab a parented widget or a shown window, so the
`parent() is None` clause excludes them.
A caller that DOES expect the sheet in a grab of an unshown parentless
widget will not get it, and has to show it or parent it.
:param widget: the object the filter is looking at. It has already
answered True to ``isWindow()``.
:returns: ``True`` to leave the widget alone until it is shown or
parented.
"""
return bool(
widget.parent() is None
and widget.windowType() == Qt.Window
and not widget.testAttribute(Qt.WA_WState_Created)
and not widget.isVisible()
)
#: Marks a menu whose sheet has been moved from its ``Polish`` to its
#: ``aboutToShow``, so the connection is made once however many times Qt
#: polishes it.
_SHEETS_AT_ABOUT_TO_SHOW = "_spacr_sheets_at_about_to_show"
def _sheet_the_menu_behind(reference) -> None:
"""Sheet the menu ``reference`` still points at, if it is still there."""
widget = reference()
if widget is None:
return
try:
for root in _roots_for(widget):
_sheet_one_window(root)
except (AttributeError, RuntimeError):
pass
def _sheets_itself_before_it_shows(widget) -> bool:
"""Move a menu's sheet from its ``Polish`` to its ``aboutToShow``.
WHY A MENU IS NOT LIKE A DIALOG. One Regression open polishes about
five hundred popups -- the page's plots alone bring 351 parentless
QMenus, pyqtgraph's ViewBoxMenu and its submenus among them -- and
sheeting them all at ``Polish`` cost about 825 ms of the open. Almost
none of them is ever shown.
``aboutToShow`` is emitted by ``QMenu.popup`` and ``QMenu.exec``
BEFORE Qt measures the menu, so a menu sheeted there has the same
geometry it would have had sheeted at its polish, and a menu nobody
opens is never sheeted at all. This is preferred over letting
a parented popup inherit its page's sheet, which would also have
covered a QComboBox's popup -- a QFrame with no such signal -- but
would have let a page's registered blocks reach inside its menus.
``Show`` REMAINS THE BELT TO THIS BRACE. A menu shown by ``show()``
rather than ``popup()`` emits no ``aboutToShow``, and is sheeted at
its ``Show`` like any other window, so nothing goes unsheeted.
:param widget: the window being polished.
:returns: True when the sheet is now that menu's own business and the
polish should leave it alone.
"""
signal = getattr(widget, "aboutToShow", None)
if signal is None or not hasattr(signal, "connect"):
return False
try:
if widget.property(_SHEETS_AT_ABOUT_TO_SHOW):
return True
widget.setProperty(_SHEETS_AT_ABOUT_TO_SHOW, True)
signal.connect(
partial(_sheet_the_menu_behind, weakref.ref(widget)))
except (AttributeError, RuntimeError):
return False
return True
class _SheetsEveryWindowThatAppears(QObject):
"""Gives a window born after a theme change the theme, not the last one.
THIS IS THE WHOLE RISK OF NOT USING `QApplication.setStyleSheet`. That
call covers every widget that exists AND every one created later --
dialogs, popups, menus, a screen built after the change -- and the
failure mode of not reproducing it is a dialog opening in the previous
theme.
A per-window sheet has to reproduce that, and the moment to do it is
`QEvent.Polish` -- Qt's own "this widget is about to need its style" --
with `QEvent.Show` as the belt to its braces for a window that was
polished before the sheet existed. Both are idempotent through the
serial, so the pair costs one application.
MENUS AND TOOLTIPS ARE COVERED BY THIS and were the part 380 recorded as
"the remaining display question ... a test cannot open a native menu".
They are covered because a QMenu and a tooltip are ordinary top-level
QWidgets that get a Polish event like any other, so the filter reaches
them without anything having to know they exist.
"""
def eventFilter(self, watched, event): # noqa: N802 - Qt override
"""Sheet a window at the moment Qt says it needs its style.
A `Polish` ON A WIDGET THAT IS NOT YET REALLY A WINDOW IS NOT THE
MOMENT, and skipping it is 3,817 ms of a 10,518 ms sweep --
:func:`_a_window_for_want_of_a_parent` has
the measurement and the argument for why nothing goes unsheeted.
The test is applied to `Polish` only: a `Show` means the widget is
appearing whatever its parentage, and that is always the moment.
:param watched: the object the event is for. A window is sheeted
along with the roots it belongs to, a widget marked as a
sheet target is sheeted on its own, and anything else is left
alone.
:param event: the event. Only ``Polish`` and ``Show`` do
anything, and both are idempotent through the serial, so the
pair costs one application.
:returns: ``False`` always. The event is observed and never
consumed, because swallowing a polish or a show would stop
the widget being styled or shown at all.
"""
kind = event.type()
if kind in _SHEETING_MOMENTS:
try:
if watched.isWindow():
if kind == QEvent.Polish:
if _a_window_for_want_of_a_parent(watched):
return False
if _sheets_itself_before_it_shows(watched):
return False
for root in _roots_for(watched):
_sheet_one_window(root)
elif watched.property(_SHEET_TARGET):
_sheet_one_window(watched)
except (AttributeError, RuntimeError):
pass
return False
#: Marks a widget that is NOT a window but is sheeted as if it were: a
#: module screen that a window has asked to carry its own copy, so the
#: window's sheet does not have to reach through it.
_SHEET_TARGET = "_spacr_is_a_sheet_target"
[docs]
def mark_as_a_sheet_target(widget) -> None:
"""Have ``widget`` carry the application sheet in its own right.
FOR A WIDGET A WINDOW DOES NOT WANT TO SHEET THROUGH. A module screen
is the case: sheeting the window reaches every hidden screen with it,
and with four modules open that is 7,595 of the window's 8,002 widgets
repolished so that one screen can change colour.
A marked widget is sheeted when the sheet changes IF IT IS VISIBLE, and
on its next `showEvent` otherwise -- which is what makes not sheeting
it now safe.
:param widget: the widget to sheet in its own right. A window may be
passed and the mark is then redundant -- the event filter sheets
every window on sight -- but it is not an error.
"""
try:
widget.setProperty(_SHEET_TARGET, True)
except (AttributeError, RuntimeError):
pass
def _roots_for(window):
"""What to sheet for ``window``: itself, or the roots it nominates.
A window that knows its own structure can say which widgets should
carry the sheet instead of it -- `MainWindow` nominates its chrome and
the screen that is actually on show. Anything that does not answer is
sheeted whole, which is every dialog, menu and tooltip.
THE WINDOW DECIDES, NOT THIS MODULE. Enumerating a main window's
furniture from here would put one screen's layout into the theme
engine and be wrong the moment either changed.
"""
nominate = getattr(window, "stylesheet_roots", None)
if not callable(nominate):
return [window]
try:
roots = [root for root in nominate() if root is not None]
except Exception: # noqa: BLE001
return [window]
return roots or [window]
def _sheet_one_window(window) -> bool:
"""Put the live window sheet on ``window`` if it has not got it yet."""
app = QApplication.instance()
if app is None:
return False
sheet = getattr(app, _WINDOW_SHEET_ATTRIBUTE, None)
if sheet is None:
return False
serial = getattr(app, _WINDOW_SHEET_SERIAL, 0)
try:
if (window.property(_WINDOW_SHEET_SERIAL) == serial
and window.property(_WINDOW_SHEET_DIGEST)
== _sheet_digest(window.styleSheet())):
return False
window.setProperty(_WINDOW_SHEET_SERIAL, serial)
own = _the_windows_own_stylesheet(window)
text = preserve_widget_qss_overlay(window, sheet + own)
window.setStyleSheet(text)
window.setProperty(_WINDOW_SHEET_DIGEST, _sheet_digest(text))
window.setProperty(_WINDOW_SHEET_BASE_LEN, len(sheet))
window.setProperty(_WINDOW_SHEET_BASE_DIGEST, _sheet_digest(sheet))
if window.property(_WINDOW_SHEET_WAITS):
window.setProperty(_WINDOW_SHEET_WAITS, None)
except (AttributeError, RuntimeError):
return False
return True
def _the_sheet_can_wait_for_the_show(widget) -> bool:
"""Can ``widget``'s sheet be put on at its first show instead of now?
A MODULE SCREEN WAS SHEETED FOUR TIMES BEFORE IT WAS FIRST PAINTED, and
each time Qt repolished every widget it had. Counted on one Regression
open (1,385 widgets), by wrapping ``QWidget.setStyleSheet``:
1. ``AppScreen._sync_page_palette``, still inside ``__init__``,
put the whole sheet on the parentless screen 668 ms
2. ``MainWindow._theme_screen`` appended the late blocks 829 ms
3. ``QStackedWidget.addWidget`` reparented a widget that
now carried a sheet, which Qt answers with a repolish
of the whole subtree (not a ``setStyleSheet``; counted
as 2,771 ``StyleChange`` events, the same as the others)
4. the page's ``Show`` put on the same text again 806 ms
Only the fourth is ever seen. The page is sheeted on its ``Show``
anyway -- it is a sheet target, and that is the design
:func:`mark_as_a_sheet_target` documents -- so the first two can be
written down instead of applied, and the third then costs nothing,
because a widget with no sheet joining a parent with no sheet is not
restyled by Qt at all.
THE SAME TEST AS THE ``Polish`` SKIP, for the same reason:
:func:`_a_window_for_want_of_a_parent` is what says a widget is still
being built and is neither shown nor anyone's child, so nothing can
render it yet. And only when a window sheet is in force -- without one
there is no ``Show`` that would pay the debt, so the rule is applied at
once exactly as before.
:param widget: the widget about to be given a sheet.
:returns: ``True`` to write the sheet down and apply it at the show.
"""
app = QApplication.instance()
if app is None or getattr(app, _WINDOW_SHEET_ATTRIBUTE, None) is None:
return False
try:
return _a_window_for_want_of_a_parent(widget)
except (AttributeError, RuntimeError):
return False
def _the_sheet_is_waiting(widget) -> bool:
"""Is ``widget`` a page whose sheet has been written down and not applied?
:param widget: the root :func:`ensure_widget_qss_applied` was given.
:returns: ``True`` while the sheet the widget is owed has not landed
and nothing can see the widget, so a late block can join the debt
instead of costing a repolish of its own.
"""
app = QApplication.instance()
if app is None or getattr(app, _WINDOW_SHEET_ATTRIBUTE, None) is None:
return False
try:
return bool(widget.property(_WINDOW_SHEET_WAITS)
and widget.property(_SHEET_TARGET)
and not widget.isVisible())
except (AttributeError, RuntimeError):
return False
def _add_to_a_windows_own_rules(window, rule: str) -> bool:
"""Append ``rule`` to what ``window`` owns, in the same styling pass.
FOR A FILTER THAT DECORATES A DIALOG AS IT IS POLISHED. Setting the
dialog's stylesheet directly there made Qt restyle the whole dialog for
the one rule, and the window sheet then restyled it all again: two full
passes over every widget in Preferences, measured as about a fifth of
its opening freeze. Recorded as the dialog's own rule, the rule is put
on with the window sheet in one pass (or on its own when no window
sheet is in force, as before).
The rules the window wears now are read as its own first, and the
window is told they have been read: otherwise a window whose sheet was
replaced since it was last sheeted would have that text re-read as its
own rules when the sheet goes on, and ``rule`` would be lost.
:param window: the dialog or window whose own rules gain ``rule``.
:param rule: the QSS to append.
:returns: ``True`` when the rule was added, ``False`` when the window
already had it or could not be read.
"""
rule = str(rule or "")
try:
own = _the_windows_own_stylesheet(window)
if not rule or rule in own:
return False
window.setProperty(_WINDOW_SHEET_DIGEST,
_sheet_digest(window.styleSheet()))
except (AttributeError, RuntimeError):
return False
set_a_sheeted_widgets_own_rule(window, f"{own}\n{rule}".strip())
return True
def _sheet_digest(text):
"""A cheap fingerprint of a stylesheet, for "is this still ours".
Length and hash rather than the text, because the sheet is ~73 KB and
there is one of these per sheeted root. Python's string hash is
randomised per process and consistent within one, which is exactly the
lifetime this has to be right for.
"""
text = text or ""
return (len(text), hash(text))
def _forget_window_stylesheets(app=None) -> int:
"""Take the per-window sheet back off, and forget it was ever there.
FOR TEST ISOLATION, and it is the same class of process-global state as
the app registry and the console's level policy, both of which
`tests/qt/conftest.py` already restores. A sheet installed by one test
is worn by every window created in every test after it -- including the
tests that apply a theme the direct way, with `app.setStyleSheet`, whose
windows then carry the EARLIER theme over the top. Measured as
`HomePage inlines #000000 (dark bg)` under the light, cell and glass
themes, in company and never alone.
A WIDGET WHOSE SHEET WAS STILL OWED is cleared of the debt here too --
the mark, the digest and the rule it was holding -- and its own rule is
put on, which is what it would have been wearing had no window sheet
ever existed. It is NOT counted: no sheet was ever on it to remove.
:returns: the number of windows a sheet was removed from.
"""
app = app or QApplication.instance()
if app is None:
return 0
if not hasattr(app, _WINDOW_SHEET_ATTRIBUTE):
return 0
removed = 0
try:
delattr(app, _WINDOW_SHEET_ATTRIBUTE)
except AttributeError:
pass
try:
candidates = list(app.allWidgets())
except (AttributeError, RuntimeError):
candidates = []
for widget in candidates:
try:
if widget.property(_WINDOW_SHEET_WAITS):
widget.setProperty(_WINDOW_SHEET_WAITS, None)
widget.setProperty(_WINDOW_SHEET_DIGEST, None)
own = str(widget.property(_WINDOW_OWN_SHEET) or "")
widget.setProperty(_WINDOW_OWN_SHEET, None)
widget.setStyleSheet(preserve_widget_qss_overlay(widget, own))
continue
if widget.property(_WINDOW_SHEET_SERIAL) is None:
continue
for stamp in (_WINDOW_SHEET_SERIAL, _WINDOW_SHEET_DIGEST,
_WINDOW_SHEET_BASE_LEN,
_WINDOW_SHEET_BASE_DIGEST):
widget.setProperty(stamp, None)
own = str(widget.property(_WINDOW_OWN_SHEET) or "")
widget.setProperty(_WINDOW_OWN_SHEET, None)
widget.setStyleSheet(preserve_widget_qss_overlay(widget, own))
removed += 1
except (AttributeError, RuntimeError):
continue
return removed
[docs]
def window_stylesheet(app=None) -> Optional[str]:
"""The sheet :func:`apply_stylesheet_per_window` last installed.
The replacement for reading ``app.styleSheet()`` back: that is empty now
and says nothing about what the windows are wearing.
:param app: the application to read it off; the running one by default.
:returns: the sheet, or ``None`` if no per-window sheet is installed.
"""
app = app or QApplication.instance()
if app is None:
return None
sheet = getattr(app, _WINDOW_SHEET_ATTRIBUTE, None)
return None if sheet is None else str(sheet)
def _the_windows_own_stylesheet(window) -> str:
"""What ``window`` has set on itself, as opposed to what we set on it.
NOT CAPTURED ONCE ANY MORE, and the reason it was is still true: on the
second pass the widget is wearing our sheet, and reading that back would
fold the global rules into "its own" so they accumulate at every theme
change. The digest is what tells the two apart. When the widget's
current sheet is the text we last gave it, the remembered answer stands;
when it is NOT, somebody has set their own rules since and those rules
are the answer.
WHY THAT MATTERS. A dialog is a window and therefore a sheet root, and
several screens set a dialog's own stylesheet from a theme-refresh path
-- `WalkAxesDialog` and `UmapSearchSettingsDialog` say so in their own
docstrings, "after the application stylesheet has been composed".
Remembering only the first answer meant their rule was dropped at the
next theme change: measured, a dialog that set
`QDialog#X { background: #123456 }` after being sheeted came back from
the next theme change carrying the full sheet and none of its own rule.
"""
remembered = window.property(_WINDOW_OWN_SHEET)
ours = window.property(_WINDOW_SHEET_DIGEST)
current = str(window.styleSheet() or "")
if remembered is not None and (
ours is None or ours == _sheet_digest(current)):
return str(remembered)
suffix = getattr(window, _LOCAL_WIDGET_QSS_ATTRIBUTE, "")
if suffix and current.endswith(suffix):
current = current[:-len(suffix)]
base_len = window.property(_WINDOW_SHEET_BASE_LEN)
base_digest = window.property(_WINDOW_SHEET_BASE_DIGEST)
if (isinstance(base_len, int) and base_digest is not None
and len(current) >= base_len
and _sheet_digest(current[:base_len]) == base_digest):
current = current[base_len:]
window.setProperty(_WINDOW_OWN_SHEET, current)
return current
[docs]
def apply_stylesheet_per_window(app, sheet: str) -> int:
"""Install ``sheet`` on every top-level window instead of on ``app``.
WHY, WITH THE NUMBER. `QApplication.setStyleSheet` repolishes every
widget the process owns, and a session that has opened a few modules
owns thousands it cannot see -- `MainWindow` builds a module screen on
first navigation and keeps it in the stack afterwards. Measured on this
box, offscreen, four modules open, 9,045 live widgets of which 6,111 are
on screens nobody is looking at:
app.setStyleSheet 2,836 ms first, ~7,500 ms thereafter
every top-level window 1,684 ms first, ~1,900 ms thereafter
the visible screen alone 222 ms
A QUARTER OF THE COST FOR THE SAME PICTURE. The floor is lower still --
222 ms is what the visible screen costs on its own -- and reaching it
means not sheeting the hidden screens either, which is a bigger change
than this one.
:param app: the ``QApplication`` whose windows wear the sheet, and where
the sheet itself is parked so a window created later can find it.
``None`` is accepted and does nothing, so a caller running without
an application does not have to check first.
:param sheet: the complete application stylesheet, as
:func:`stylesheet` composes it.
:returns: the number of windows the sheet was put on.
A settings category's body that is waiting off the page is parentless,
so Qt lists it among the top-level widgets, but it is not a window: it
goes back under its page before anybody sees it and wears the page's
sheet from there. Sheeting it here would leave it carrying a copy of
the sheet of the moment when it went back, which the next theme change
would not reach. See
:meth:`spacr.qt.widgets.section.Section._detach_body_while_hidden`.
"""
global _WINDOW_SHEET_FILTER
if app is None:
return 0
if app.styleSheet():
app.setStyleSheet("")
setattr(app, _WINDOW_SHEET_ATTRIBUTE, str(sheet))
setattr(app, _WINDOW_SHEET_SERIAL,
int(getattr(app, _WINDOW_SHEET_SERIAL, 0)) + 1)
if _WINDOW_SHEET_FILTER is None:
_WINDOW_SHEET_FILTER = _SheetsEveryWindowThatAppears()
from .gil_priority import _watch_application_events
_watch_application_events(app, _WINDOW_SHEET_FILTER, _SHEETING_MOMENTS)
sheeted = 0
for window in list(app.topLevelWidgets()):
if (_sheets_itself_before_it_shows(window)
and not window.isVisible()):
continue
if getattr(window, "_spacr_detached_from", None) is not None:
continue
for root in _roots_for(window):
if _sheet_one_window(root):
sheeted += 1
return sheeted
[docs]
def page_tabs_qss(object_name: str, palette: dict, opacity=None) -> str:
"""Home's tab treatment, for a tab strip that IS the page.
The shipped ``QTabBar``/``QTabWidget::pane`` rules paint
``P["surface"]`` and ``P["surface_alt"]`` — **raw hex**, so a tab
strip that is the main content of a screen (Classifier Evaluation,
Run History) sat there as a flat opaque slab while the cards beside
it thinned with the slider.
This is the same shape Home uses, at the page opacity: rounded top
corners, a dark-grey tab by default, the accent blue under the
pointer, and a rounded translucent pane below it.
Register it per screen rather than making it a blanket rule — a tab
strip *inside* a card is on a surface already and must keep the
shipped look, or it double-fills.
:param object_name: ``objectName`` of the ``QTabWidget``.
:param palette: the palette handed to a registered block, including
the reserved ``theme`` key.
:param opacity: the page-opacity preference, passed straight
through — ``None`` here means the theme's designed scrim, which is
why this reads :func:`block_surface` and not :func:`pane_surface`.
"""
theme = palette.get("theme", "dark")
scale = palette.get("font_scale")
pane = block_surface("surface_alt", theme, opacity)
tab = block_surface("surface", theme, opacity)
return f"""
QTabWidget#{object_name}::pane {{
background: {pane};
border: 1px solid {palette["border_soft"]};
border-radius: {RADIUS["md"]}px;
top: -1px;
}}
/* The bar itself, not the tabs on it. Qt builds `qt_tabwidget_tabbar`
and with no rule of its own it takes the blanket window fill. */
QTabWidget#{object_name} > QTabBar {{
background: transparent;
}}
QTabWidget#{object_name} > QTabBar::tab {{
background: {tab};
color: {palette["fg_muted"]};
border: 1px solid {palette["border_soft"]};
border-bottom: none;
border-top-left-radius: {RADIUS["md"]}px;
border-top-right-radius: {RADIUS["md"]}px;
padding: 7px 14px;
margin-right: 2px;
font-size: {font_px("body", scale)}px;
}}
QTabWidget#{object_name} > QTabBar::tab:hover {{
background: {palette["accent"]};
color: {palette["bg"]};
}}
QTabWidget#{object_name} > QTabBar::tab:selected {{
background: {pane};
color: {palette["accent"]};
border-bottom-color: {pane};
}}
/* The container below the tabs. Qt builds the page stack itself, and a
read-only detail view is a *display*, not a field — left as one it
takes the shipped input fill and paints an opaque rectangle over the
pane that was just made translucent, which is the bare dark area
again one layer down. The pane is the panel; everything sitting on it
shows it through. */
QTabWidget#{object_name} > QStackedWidget {{
background: transparent;
}}
QTabWidget#{object_name} QPlainTextEdit[readOnly="true"],
QTabWidget#{object_name} QTextEdit[readOnly="true"] {{
background: transparent;
border: none;
}}
"""
[docs]
def stylesheet(theme: str = "dark", font_scale: float = 1.0,
background: Optional[str] = None,
surface_opacity: Optional[float] = None, *,
load_widget_registrars: bool = True) -> str:
"""Return the QSS string that styles every custom widget in the app.
Blocks registered with :func:`register_widget_qss` are appended after
everything below, so a widget's own rules win a specificity tie
against the general ones.
:param theme: one of :data:`THEMES`; unknown values fall back to dark.
:param font_scale: multiplier applied to every font size in
:data:`FONT_SIZE`. 1.0 = 100 %.
:param background: path to a background image. Only the themes in
:data:`IMAGE_THEMES` use it; ``None`` (the default, and what a
first run mid-generation gets) falls back to a flat gradient.
:param surface_opacity: optional user-requested alpha for all shared
module surfaces. ``None`` uses the theme's designed scrims.
:param load_widget_registrars: import every module that contributes a
widget block before composing. This remains the public default for
exhaustive callers and tests. Application startup passes ``False``
so an unopened data screen cannot pull the scientific stack into the
first frame; ``MainWindow`` scopes late blocks to a new screen before
inserting that screen into the visible stack.
"""
base = palette_for(theme)
if load_widget_registrars:
load_widget_qss_registrars()
S = SPACING
R = RADIUS
P = _widget_qss_palette(theme, font_scale, surface_opacity)
ELEVATED = css_color(
base["surface_alt"], panel_alpha(theme, "elevated", surface_opacity))
#: The menu bar, and everything drawn onto it: its items, and the
#: window chrome in its corner.
#:
#: READ FROM :data:`MENU_BAR_ALPHA`, NOT WRITTEN AGAIN HERE. This was a
#: second hand-written `0.94`, and it silently outranked the constant:
#: `MENU_BAR_ALPHA` was set to 1.0 to make the bar opaque on a real
#: screen, `menu_bar_background()` returned `#0d0e10`
#: correctly, and the generated stylesheet went on emitting
#: `rgba(13, 14, 16, 0.940)` because this line never asked.
#:
#: The bar is the frameless window's title bar, so what shows through it
#: is the animated backdrop moving under the only two words on it.
#: `test_the_menu_bar_never_shows_the_window_behind_it` says in its own
#: docstring that deriving the colour "is what stops the corner chrome
#: and the bar drifting apart"; they had drifted anyway, because the
#: derivation had a copy.
BAR_BG = css_color(base["surface"], MENU_BAR_ALPHA)
over_image = theme in IMAGE_THEMES
TILE_BG = css_color(base["surface"],
panel_alpha(theme, "tile", surface_opacity))
TROUGH = "transparent" if over_image else page_colour(theme)
CONSOLE_BG = (P["surface_alt"] if over_image else css_color(
"#0a0b0d", panel_alpha(theme, "surface_alt", surface_opacity)))
DOCK_BG = (dock_colour(theme) if over_image else css_color(
dock_colour(theme),
panel_alpha(theme, "surface_alt", surface_opacity)))
#: What the dock actually paints. See the Sidebar block below: 369 takes
#: the container off, #16j says it may never be transparent over a
#: picture, and `over_image` is the seam that was already carrying that
#: distinction.
DOCK_FILL = DOCK_BG if over_image else "transparent"
RIM = rim_colour(theme)
SELECTION_INK = selection_ink(theme)
STAGE_RULES = "\n".join(
f"""QPushButton#AppTile[stage="{stage}"]:hover {{
background-color: {css_color(hue, 0.22)};
border: 1px solid {hue};
}}
QPushButton#AppTile[stage="{stage}"]:pressed {{
background-color: {css_color(hue, 0.40)};
border: 1px solid {hue};
}}"""
for stage, hue in STAGE_HOVER.items())
FOLD_STAGE_RULES = "\n".join(
f"""QPushButton#FoldButton[stage="{stage}"]:hover {{
background-color: {css_color(hue, 0.22)};
border: 1px solid {hue};
}}
QPushButton#FoldButton[stage="{stage}"]:pressed {{
background-color: {css_color(hue, 0.40)};
border: 1px solid {hue};
}}"""
for stage, hue in STAGE_HOVER.items())
SECTION_STAGE_RULES = "\n".join(
f"""QFrame#SectionCard[maturity="{stage}"] {{
border: 1px solid {css_color(hue, 0.72)};
border-left: 4px solid {hue};
}}
QToolButton#SectionHeader[maturity="{stage}"]:hover,
QToolButton#SectionHeader[maturity="{stage}"]:checked {{
background-color: {css_color(hue, 0.14)};
}}"""
for stage, hue in STAGE_HOVER.items()) + f"""
QToolButton#SectionHeader[maturity="alpha"] {{
color: {ALPHA_INK};
}}"""
TILE_MIN_H = max(1, int(round(TILE_H * font_scale)))
TILE_MIN_W_PX = max(1, int(round(TILE_W * font_scale)))
F = {k: max(_QT_MIN_FONT_PX, int(round(v * font_scale)))
for k, v in FONT_SIZE.items()}
GLASS_LAYER = (
_glass_material_layer(base, surface_opacity)
if theme == "glass" else ""
)
WIDGET_QSS = registered_widget_qss(P, surface_opacity)
CLOSE_MARK_RULES = close_mark_rules(theme, F["body"])
return f"""
/* -----------------------------------------------------------------
* Base
* ----------------------------------------------------------------- */
{_window_block(theme, base, background, F["body"], _the_backdrop_is_on())}
/* Page surfaces — see `make_transparent`. A widget carrying this
* property paints nothing at all, so whatever sits behind the page
* shows through it: the wallpaper in an image theme, the DNA rain on
* the sequencing screen. Cards, panels and inputs are NOT tagged, so
* they keep their surface and stay the readable thing on top.
*
* An attribute selector outranks the bare `QWidget` type selector in
* QSS specificity, so this wins in every theme whatever the rule order
* — which matters for dark and light, where `QWidget` is an opaque
* `bg` and used to bury the rain under the first container it met. */
*[{TRANSPARENT_PROPERTY}="true"] {{
background: transparent;
}}
/* The opposite declaration — see `mark_surface`. A view that IS the page,
* rather than a passenger on a pane, needs a surface of its own, and most
* of them would not get one even if the sweep left them alone: the shipped
* rule covers QTableView/QTreeView but nothing covers a bare QListWidget,
* which then falls through to the blanket `QWidget` fill and paints the
* WINDOW colour, which is not a surface.
*
* Background only. Borders, radii and item padding stay with whatever type
* rule the widget already matched, so a marked table still looks like a
* table; QSS cascades per property, and only this one is being decided
* here. `P["surface_alt"]` already carries the user's page opacity. */
*[{SURFACE_PROPERTY}="true"] {{
background-color: {P["surface_alt"]};
}}
/* Every QLabel is transparent by default so it inherits the bg of
* whatever container it lives in (surface, surface_alt, hero card,
* etc). Individual labels can override with their own object name. */
QLabel {{
background: transparent;
}}
/* Settings labels are wrapped with a layout-only QWidget that right-aligns
* the text against its field. A bare QWidget inherits the window canvas
* colour; without this rule that wrapper paints a black rectangle on the
* section's dark-gray surface even though the QLabel itself is transparent.
* These wrappers are structural and must show their actual container
* through. */
QWidget#SettingLabelWithInfo,
QWidget#SettingControlWithInfo,
QWidget#SettingLinkStack {{
background: transparent;
}}
/* Grey out text on disabled widgets (e.g. a live-preview compartment panel
that isn't the chosen object) so the label reads as inactive too, not just
the field. */
QLabel:disabled, QCheckBox:disabled, QGroupBox:disabled,
QGroupBox::title:disabled, QRadioButton:disabled {{
color: {P["fg_dim"]};
}}
/* -----------------------------------------------------------------
* Menu bar + menus
* ----------------------------------------------------------------- */
QMenuBar {{
/* ONE FLAT, MOSTLY-OPAQUE COLOUR. This bar is the frameless window's
title bar, so it sits over the animated backdrop -- and read
through a fully translucent bar that backdrop is a moving gradient
behind the only two words on it. Reported from macOS: "the bar is
transparent and has a gradient so it is hard to see the spaCR and
Help". A little translucency keeps it from looking pasted on; the
rest is what makes the labels legible over anything. */
background-color: {BAR_BG};
color: {P["fg_muted"]};
padding: {S["xs"]}px {S["sm"]}px;
border-bottom: 1px solid {P["border_soft"]};
font-size: {F["small"]}px;
}}
QMenuBar::item {{
/* THE BAR'S OWN COLOUR, NEVER `transparent`. `transparent` means
"paint nothing", and what is behind this bar is the WINDOW, whose
palette Window role is the splash colour -- pure black. On Linux
the bar's own fill covers that and nothing shows; on macOS the
hover repaint clears to the window first, and the black came
through as a box behind each label. Painting the bar's colour
here is indistinguishable from transparent wherever transparent
worked, and correct where it did not. */
background: {BAR_BG};
padding: {S["xs"]}px {S["sm"]}px;
border-radius: {R["sm"]}px;
}}
QMenuBar::item:selected, QMenuBar::item:pressed {{
/* THE WORD LIGHTS, not a plate behind it: the same accent the dock's
open section header takes, so pointing at spaCR or Help reads the
same way as pointing at a category. The background repeats the
bar's colour rather than being `transparent` for the reason
above -- this is the exact state the black box appeared in. */
background: {BAR_BG};
color: {P["accent"]};
}}
QMenu {{
background-color: {ELEVATED};
color: {P["fg"]};
border: 1px solid {P["border"]};
border-radius: {R["md"]}px;
padding: {S["xs"]}px;
}}
QMenu::item {{
padding: {S["xs"]}px {S["md"]}px;
border-radius: {R["sm"]}px;
background: transparent;
}}
QMenu::item:selected {{
background: {P["accent"]};
color: {P["bg"]};
}}
QMenu::separator {{
height: 1px;
background: {P["border"]};
margin: {S["xs"]}px {S["sm"]}px;
}}
/* -----------------------------------------------------------------
* Sidebar (main window navigation)
* ----------------------------------------------------------------- */
/* THE TRAY GOES ON THE FLAT THEMES AND STAYS OVER A PICTURE, AND THAT SPLIT
IS TWO MAINTAINER REQUESTS THAT DISAGREE.
2026-09-02, instruction 369: "the background dark gray container can be
removed, the hover highlight should stay."
Earlier, #16j: "the dock to the left should never have a transparent
background, either dark gray or white" -- filed because on Space the app
list was a ghost with a galaxy behind every row.
Both are real. Taken literally the second forbids the first. The split
already in this file resolves it, and 369 is applied along the SAME seam
rather than a new one: on `dark` and `light` there is no wallpaper behind
the dock -- only the ambient animation -- so the container comes off and
that is exactly what was asked for. Over `space` and `cell` there IS a
picture, #16j's complaint applies verbatim, and the legibility floor does
not rescue it (Cell floors at 0.047), so the dock stays opaque there.
THE EDGE IS KEPT EITHER WAY: `#Sidebar` still draws its right border, so
the page still ends at a line rather than bleeding into the dock.
THE HOVER HIGHLIGHT SURVIVES BECAUSE IT WAS NEVER THE TRAY. It is drawn
by the row itself, in `_DockRow._paint_plate`, and translucently on
purpose: removing the plane behind it changes what it sits ON, not
whether it is drawn. (It is NOT the `QPushButton#SidebarItem:hover`
rule below, which this comment used to claim and which reaches no dock
row -- see the note on that rule.)
IF THE MAINTAINER WANTS IT GONE OVER THE PICTURES TOO, this is one line:
drop the `over_image` arm of `DOCK_FILL` below. */
#EdgeDrawer, #Sidebar, #SidebarScroll, #SidebarInner {{
background-color: {DOCK_FILL};
}}
/* NO RIGHT BORDER. The dock is a rounded slab painted by `Sidebar.
paintEvent` (2026-09-03), and a full-height 1 px rule down its right edge
cuts straight across the two corners it just rounded. The slab draws its
own hairline edge, all the way round, which is what separates the dock
from the page now. */
#Sidebar {{
border: none;
}}
#SidebarTitle {{
color: {P["accent"]};
font-family: "Open Sans", "Segoe UI", "Helvetica Neue", sans-serif;
font-size: {font_px(24, font_scale)}px;
font-weight: 300; /* Light */
letter-spacing: -0.5px;
padding: {S["lg"]}px {S["md"]}px {S["md"]}px;
background: {DOCK_FILL};
}}
#SidebarSection {{
color: {P["fg_dim"]};
font-size: {F["xs"]}px;
font-weight: 600;
padding: {S["md"]}px {S["md"]}px {S["xs"]}px;
text-transform: uppercase;
letter-spacing: 1px;
background: {DOCK_FILL};
}}
/* AN OPEN SECTION IS BLUE, and so is one under the pointer. The header is
the control that opens it, and a control that looks identical whether it
is on or off is a control nobody learns. */
#SidebarSection[open="true"], #SidebarSection[hovered="true"] {{
color: {P["accent"]};
}}
/* A DOCK ROW PAINTS NO BOX, IN ANY STATE, and every state is listed below
so none can be added back by accident.
`:hover` used to fill `surface_hi`, and `:checked` filled it and added a
3 px accent bar. Those are the "fields which appear whne hovered" the
maintainer asked to remove on 2026-09-03: "i just want the transparent
dock holder with rounded edges, the icons and when hovered the icons turn
blue and you see the text which is also blue. nothing else."
`background-color` AND `background`, AND EVERY STATE SPELLED OUT. Both of
those are load-bearing, and getting either wrong cost four failed fixes
on 2026-09-03 -- the box was reported, "fixed", and reported again, four
times.
The generic rules above this file's dock section paint EVERY QPushButton:
QPushButton background-color: surface_alt (#161719)
QPushButton:hover background-color: surface_hi (#1f2124)
QPushButton:pressed background-color: <accent> (blue)
QPushButton:checked background-color: <selection>
This rule used to say `background: transparent` only. An id selector beats
a type selector, so that looks like it should win -- and it does, for the
property it names. Qt merges declarations PER PROPERTY, and `background`
and `background-color` are two properties: setting one leaves the other
exactly as the generic rule left it. So `#161719` was painted behind every
icon at rest, `#1f2124` appeared under the pointer, and `:pressed` flashed
the accent. That is the "black box", the "fields which appear whne
hovered", and the blue flash, all of them, from one missing word.
It survived every measurement because `QWidget.render()` and `grab()` do
not put a widget through the stylesheet's background pass the way a live
paint does -- so every probe reported a clean row while the running
application drew the box.
Painting a QSS background is ALSO not something a `paintEvent` can
prevent: QStyleSheetStyle fills it from `QWidget::event(QEvent::Paint)`
before `paintEvent` runs. Removing the `drawControl(CE_PushButton)` call
from `app.py` was worth doing -- it was rendering a native button panel
from the palette on top of this -- but it could never have been enough on
its own.
The dock's own translucent rounded slab is painted by
`Sidebar.paintEvent`, and it is the only box in the column. */
QPushButton#SidebarItem,
QPushButton#SidebarItem:hover,
QPushButton#SidebarItem:pressed,
QPushButton#SidebarItem:checked,
QPushButton#SidebarItem:checked:hover,
QPushButton#SidebarItem:disabled,
QPushButton#SidebarItem[selected="true"] {{
background: transparent;
background-color: transparent;
border: none;
padding: {S["sm"]}px {S["md"]}px;
text-align: left;
font-size: {F["body"]}px;
}}
/* -----------------------------------------------------------------
* Cards / grouped sections
* ----------------------------------------------------------------- */
QFrame#Card {{
/* A clear dark-gray rounded box sitting on the black app background —
same surface as the console box so System/Figures/console read as one
consistent family of boxes. */
background-color: {P["surface_alt"]};
border: 1px solid {P["border_soft"]};
border-radius: {R["md"]}px;
}}
/* The masthead — logo, spaCR wordmark, end-to-end subtitle. No fill and no
rim: it is type on the page, not a card. It used to paint a diagonal
gradient with a border, which read as a black box drawn around the brand. */
QFrame#Hero {{
background: transparent;
border: none;
}}
QPushButton#Tile:hover {{
border: 1px solid {P["accent"]};
background-color: {P["surface_hi"]};
}}
QLabel#CardTitle {{
color: {P["fg"]};
font-size: {F["header"]}px;
font-weight: 600;
padding: 0px;
background: transparent;
}}
QLabel#CardSubtitle {{
color: {P["fg_muted"]};
font-size: {F["small"]}px;
background: transparent;
}}
QFrame#Divider {{
background: {P["border"]};
max-height: 1px;
min-height: 1px;
border: none;
}}
/* -----------------------------------------------------------------
* Startup tiles
* ----------------------------------------------------------------- */
QPushButton#Tile {{
background-color: {P["surface"]};
color: {P["fg"]};
border: 1px solid {P["border_soft"]};
border-radius: {R["lg"]}px;
padding: {S["md"]}px;
font-size: {F["body"]}px;
text-align: center;
min-width: 96px;
min-height: 96px;
}}
QPushButton#Tile:hover {{
border: 1px solid {P["accent"]};
background-color: {P["surface_alt"]};
color: {P["accent"]};
}}
QPushButton#Tile:pressed {{
background-color: {P["accent_lo"]};
color: {P["bg"]};
}}
QLabel#TileCaption {{
color: {P["fg"]};
font-size: {F["body"]}px;
font-weight: 500;
background: transparent;
padding-top: 4px;
}}
/* -----------------------------------------------------------------
* Horizontal tiles (HTile) — icons-left cards on the home screen
* ----------------------------------------------------------------- */
/* Every tile carries a hairline rim in the theme's ink — white on the
dark themes, near-black on the light one. It is not decoration: with
the descriptions gone the tiles are icon + name on the pane's own
colour, and the rim is the only thing that says where one button ends
and the next begins. `border: 1px solid transparent` (what this used
to be) drew nothing at all until you hovered. */
QPushButton#HTile {{
background-color: {TILE_BG};
color: {P["fg"]};
border: 1px solid {css_color(RIM, 0.35)};
border-radius: {R["lg"]}px;
padding: 12px 14px 12px 20px;
text-align: left;
font-family: "Open Sans", "Segoe UI", "Helvetica Neue", sans-serif;
}}
QPushButton#HTile:hover {{
background-color: {css_color(STAGE_HOVER["stable"], 0.22)};
border: 1px solid {STAGE_HOVER["stable"]};
}}
QPushButton#HTile:pressed {{
background-color: {css_color(STAGE_HOVER["stable"], 0.40)};
border: 1px solid {STAGE_HOVER["stable"]};
}}
/* -----------------------------------------------------------------
* Module tiles (AppTile) — icon over name, one size, every Home tab
* ----------------------------------------------------------------- */
/* `min-height` and `min-width` are the point of this rule existing
separately from `#HTile`. See TILE_H in theme.py: without them the
blanket 22 px QPushButton minimum lets a full page squash every tile
and draw the name over the icon. Zero padding, because the tile's own
QVBoxLayout owns its margins — inheriting HTile's left-weighted
padding would push a centred icon off centre. */
QPushButton#AppTile {{
background-color: {TILE_BG};
color: {P["fg"]};
border: none;
border-radius: {R["lg"]}px;
padding: 0px;
min-height: {TILE_MIN_H}px;
min-width: {TILE_MIN_W_PX}px;
text-align: center;
font-family: "Open Sans", "Segoe UI", "Helvetica Neue", sans-serif;
}}
/* Fallback hover, for a tile whose `stage` property was never set. The
three stage rules below win over it wherever it was. */
QPushButton#AppTile:hover {{
background-color: {css_color(STAGE_HOVER["stable"], 0.22)};
border: 1px solid {STAGE_HOVER["stable"]};
}}
QPushButton#AppTile:pressed {{
background-color: {css_color(STAGE_HOVER["stable"], 0.40)};
border: 1px solid {STAGE_HOVER["stable"]};
}}
/* Maturity, as colour. See `STAGE_HOVER` and the legend under the Home
aside — a tile that lights magenta is a beta module, and the legend
beside it is what says so. */
{STAGE_RULES}
/* A resting rim is decoration; a keyboard-focus ring carries state. Keep it
after the maturity rules so focus remains visible while a tile is hovered. */
QPushButton#AppTile:focus {{
border: 1px solid {P["accent"]};
}}
QLabel#HTileName {{
color: {P["fg"]};
font-family: "Open Sans", "Segoe UI", "Helvetica Neue", sans-serif;
font-size: {F["subtitle"]}px;
font-weight: 400; /* Open Sans Regular */
background: transparent;
letter-spacing: -0.1px;
}}
QLabel#HTileDesc {{
color: {P["fg_muted"]};
font-family: "Open Sans", "Segoe UI", "Helvetica Neue", sans-serif;
font-size: {F["small"]}px;
font-weight: 300; /* Open Sans Light */
background: transparent;
}}
/* Home hero subtitle ("End-to-end microscopy → …"): styled here (not inline)
so it scales with the font-size preference and reads in the primary
(white on dark) text colour. Sized at "subtitle" rather than "body" so it
carries the masthead beside the enlarged wordmark instead of trailing it
as ordinary paragraph text; it still moves with the font preference. */
QLabel#HeroSubtitle {{
color: {P["fg"]};
font-family: "Open Sans", "Segoe UI", "Helvetica Neue", sans-serif;
font-size: {F["subtitle"]}px;
font-weight: 300;
background: transparent;
padding-left: 8px;
}}
/* Sticky bottom hint bar ("Hover a tile to see what it does."): also styled
here so it scales with the font preference and uses primary text colour. */
QLabel#HintBar {{
background-color: {P["surface_alt"]};
border-top: 1px solid {P["border_soft"]};
color: {P["fg"]};
font-family: "Open Sans", "Segoe UI", "Helvetica Neue", sans-serif;
font-weight: 300;
font-size: {F["body"]}px;
padding: 8px 12px;
}}
/* -----------------------------------------------------------------
* Sliders — blue (accent) handle + filled track, not the default
* dark-gray handle.
* ----------------------------------------------------------------- */
/* THE WIDGET'S OWN BACKGROUND, which no rule had claimed. The groove and
handle were styled and the QSlider behind them was not, so it painted the
palette's window colour as an opaque rectangle on a container that is a
translucent SURFACE -- reported as "there is a figure size slider that has
a black background in regression module, it should be same color as
container". A figure control is not a window (INVARIANTS 2); the same
omission is what made the tab overflow arrows black boxes. */
QSlider {{
background: transparent;
}}
/* ONE SIZE FOR THE HANDLE, SO IT STAYS A CIRCLE. Reported as "the size
slider in the plaque modual has a square on the slider, this should be a
circle". The handle's length was its own width (16px) and its thickness
was the groove's 4px plus two -6px margins, three numbers the GUI scale
rounds one by one: at 75 % that is 12 by 3 + 4 + 4 = 11, and Qt drops
every corner of a box whose radii (6 + 6) add up to more than its side,
so the circle became a square on every slider. The groove is now as
tall as the handle, the track a 4px band painted across its middle, the
handle has no margin, and the slider is at least that tall; length and
thickness are then the same rounded 16px at any scale, and radii round
down (gui_scale._scaled_px) so 8px never outgrows half of it. */
QSlider:horizontal {{
min-height: 16px;
}}
QSlider:vertical {{
min-width: 16px;
}}
QSlider::groove:horizontal {{
height: 16px;
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 transparent, stop:0.37 transparent,
stop:0.38 {P["border"]}, stop:0.62 {P["border"]},
stop:0.63 transparent, stop:1 transparent);
}}
QSlider::sub-page:horizontal {{
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 transparent, stop:0.37 transparent,
stop:0.38 {P["accent_lo"]}, stop:0.62 {P["accent_lo"]},
stop:0.63 transparent, stop:1 transparent);
}}
QSlider::handle:horizontal {{
background: {P["accent"]};
width: 16px;
height: 16px;
margin: 0;
border-radius: 8px;
}}
QSlider::handle:horizontal:hover {{
background: {P["accent_hi"]};
}}
QSlider::groove:vertical {{
width: 16px;
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 transparent, stop:0.37 transparent,
stop:0.38 {P["border"]}, stop:0.62 {P["border"]},
stop:0.63 transparent, stop:1 transparent);
}}
QSlider::handle:vertical {{
background: {P["accent"]};
width: 16px;
height: 16px;
margin: 0;
border-radius: 8px;
}}
QSlider::handle:vertical:hover {{
background: {P["accent_hi"]};
}}
/* -----------------------------------------------------------------
* Typography helpers — pair each role with weight + tracking
* ----------------------------------------------------------------- */
QLabel#Hero {{
color: {P["fg"]};
font-size: {F["hero"]}px;
font-weight: 200;
letter-spacing: -0.5px;
background: transparent;
}}
QLabel#DisplayHeading {{
color: {P["fg"]};
font-size: {F["display"]}px;
font-weight: 300;
letter-spacing: -0.4px;
background: transparent;
}}
QLabel#TitleHeading {{
color: {P["fg"]};
font-size: {F["title"]}px;
font-weight: 500;
letter-spacing: -0.2px;
background: transparent;
}}
QLabel#Subtitle {{
color: {P["fg_muted"]};
font-size: {F["subtitle"]}px;
font-weight: 400;
background: transparent;
}}
QLabel#SubtitleSmall, QLabel#Muted {{
color: {P["fg_muted"]};
font-size: {F["small"]}px;
background: transparent;
}}
QLabel#SettingChipText, QToolButton#SettingListFooter {{
font-size: {font_px(12, font_scale)}px;
}}
QLabel#TourStep {{
font-size: {font_px(10, font_scale)}px;
font-family: 'Open Sans', sans-serif;
font-weight: 600;
letter-spacing: 2px;
}}
QLabel#TourTitle {{
font-size: {font_px(20, font_scale)}px;
font-family: 'Open Sans', sans-serif;
font-weight: 400;
}}
QLabel#TourBody {{
font-size: {font_px(13, font_scale)}px;
font-family: 'Open Sans', sans-serif;
font-weight: 300;
}}
QLabel#Caption {{
color: {P["fg_dim"]};
font-size: {F["xs"]}px;
font-weight: 500;
letter-spacing: 0.6px;
text-transform: uppercase;
background: transparent;
}}
QLabel#SectionHeading {{
color: {P["fg"]};
font-size: {F["header"]}px;
font-weight: 600;
background: transparent;
}}
QLabel#FoldHeading {{
color: {P["fg"]};
font-size: {F["body"]}px;
font-weight: 600;
padding: 1px 0px;
background: transparent;
}}
QWidget#FoldSection, QWidget#FoldSectionBody {{
background: transparent;
}}
/* -----------------------------------------------------------------
* Buttons
* ----------------------------------------------------------------- */
QPushButton {{
background-color: {P["surface_alt"]};
color: {P["fg"]};
border: 1px solid {P["border_soft"]};
border-radius: {R["sm"]}px;
padding: {S["sm"]}px {S["md"]}px;
min-height: 22px;
font-weight: 500;
}}
QPushButton:hover {{
background-color: {P["surface_hi"]};
border-color: {P["border"]};
color: {P["fg"]};
}}
QPushButton:pressed {{
background-color: {P["accent_lo"]};
border-color: {P["accent_lo"]};
color: {P["bg"]};
}}
QPushButton:checked {{
background-color: {P["accent_soft"]};
border-color: {P["accent"]};
color: {P["accent"]};
}}
QPushButton:checked:hover {{
background-color: {P["accent_soft"]};
border-color: {P["accent_hi"]};
color: {P["accent_hi"]};
}}
QPushButton:disabled {{
color: {P["fg_dim"]};
border-color: {P["border_soft"]};
background-color: {P["surface"]};
}}
/* Semantic action buttons: outlined at rest, softly tinted on hover, and
* solid while pressed or while an asynchronous action remains active.
* buttonActionRole is assigned centrally by button_roles.py. */
QPushButton#PrimaryButton,
QPushButton[buttonActionRole="positive"] {{
background-color: transparent;
color: {button_accent_text(base)};
border: 1px solid {P["button_accent"]};
font-weight: 600;
padding: {S["sm"]}px {S["lg"]}px;
}}
QPushButton#PrimaryButton:hover,
QPushButton[buttonActionRole="positive"]:hover {{
background-color: {css_color(P["button_accent"], 0.18)};
color: {button_accent_text(base)};
border-color: {P["button_accent"]};
}}
QPushButton#PrimaryButton:pressed,
QPushButton[buttonActionRole="positive"]:pressed,
QPushButton#PrimaryButton[buttonActionBusy="true"],
QPushButton[buttonActionRole="positive"][buttonActionBusy="true"] {{
background-color: {P["button_accent"]};
color: {P["button_accent_ink"]};
border-color: {P["button_accent"]};
}}
QPushButton#DangerButton,
QPushButton[buttonActionRole="negative"] {{
background-color: transparent;
color: {P["error"]};
border: 1px solid {P["error"]};
font-weight: 600;
padding: {S["sm"]}px {S["lg"]}px;
}}
QPushButton#DangerButton:hover,
QPushButton[buttonActionRole="negative"]:hover {{
background-color: {css_color(P["error"], 0.18)};
color: {P["error"]};
border-color: {P["error"]};
}}
QPushButton#DangerButton:pressed,
QPushButton[buttonActionRole="negative"]:pressed,
QPushButton#DangerButton[buttonActionBusy="true"],
QPushButton[buttonActionRole="negative"][buttonActionBusy="true"] {{
background-color: {P["error"]};
color: {P["bg"]};
border-color: {P["error"]};
}}
QPushButton[buttonActionRole="positive"]:disabled,
QPushButton[buttonActionRole="negative"]:disabled {{
background-color: transparent;
}}
QPushButton[buttonActionRole="positive"][buttonActionBusy="true"]:disabled {{
background-color: {P["button_accent"]};
color: {P["button_accent_ink"]};
border-color: {P["button_accent"]};
}}
QPushButton[buttonActionRole="negative"][buttonActionBusy="true"]:disabled {{
background-color: {P["error"]};
color: {P["bg"]};
border-color: {P["error"]};
}}
QPushButton#GhostButton {{
background-color: transparent;
color: {P["fg_muted"]};
border: none;
}}
QPushButton#GhostButton:hover {{
color: {P["accent"]};
background: transparent;
}}
QPushButton#IconButton {{
background-color: transparent;
border: none;
padding: {S["xs"]}px;
min-height: 0;
color: {P["fg_muted"]};
}}
QPushButton#IconButton:hover {{
color: {P["accent"]};
background: {P["surface_alt"]};
border-radius: {R["sm"]}px;
}}
/* SQL column pickers are QToolButtons, so they do not inherit the normal
QPushButton treatment. Keep them visually part of the settings card:
a dark card-coloured face, a light neutral rim and white text. */
QWidget#ColumnPickerRow {{
background: transparent;
}}
QToolButton#ColumnPickerButton {{
background-color: {P["surface"]};
color: {P["fg"]};
border: 1px solid {P["fg_muted"]};
border-radius: {R["sm"]}px;
padding: {S["xs"]}px {S["sm"]}px;
min-height: 22px;
font-weight: 500;
}}
QToolButton#ColumnPickerButton:hover {{
background-color: {P["accent_soft"]};
color: {P["fg"]};
border-color: {P["accent"]};
}}
QToolButton#ColumnPickerButton:pressed {{
background-color: {P["accent_lo"]};
color: {P["fg"]};
border-color: {P["accent_hi"]};
}}
QToolButton#ColumnPickerButton:disabled {{
background-color: {P["surface"]};
color: {P["fg_dim"]};
border-color: {P["border"]};
}}
/* -----------------------------------------------------------------
* Inputs (QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox)
* ----------------------------------------------------------------- */
QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QPlainTextEdit, QTextEdit {{
background-color: {P["surface_alt"]};
color: {P["fg"]};
border: 1px solid {P["border"]};
border-radius: {R["sm"]}px;
padding: {S["xs"]}px {S["sm"]}px;
selection-background-color: {P["accent"]};
selection-color: {P["bg"]};
placeholder-text-color: {P["fg_dim"]};
}}
QPlainTextEdit#Console {{
background-color: {CONSOLE_BG};
color: #d4d7dc;
border: 1px solid {P["border_soft"]};
font-family: "Open Sans", "Segoe UI", "Helvetica Neue", sans-serif;
font-weight: 300;
font-size: {F["small"]}px;
padding: {S["sm"]}px;
selection-background-color: {P["accent_lo"]};
}}
QFrame#ConsoleSectionResizeHandle {{
background: transparent;
border: none;
border-bottom: 1px solid {P["border_soft"]};
}}
QFrame#ConsoleSectionResizeHandle:hover {{
border-bottom: 1px solid #168cff;
}}
QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus,
QComboBox:focus, QPlainTextEdit:focus, QTextEdit:focus {{
border: 1px solid {P["accent"]};
}}
QLineEdit:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled,
QComboBox:disabled, QPlainTextEdit:disabled, QTextEdit:disabled {{
color: {P["fg_dim"]};
background-color: {P["surface"]};
border-color: {P["border_soft"]};
}}
QSpinBox::up-button, QSpinBox::down-button,
QDoubleSpinBox::up-button, QDoubleSpinBox::down-button {{
background: transparent;
border: none;
width: 16px;
}}
QSpinBox::up-arrow, QSpinBox::down-arrow,
QDoubleSpinBox::up-arrow, QDoubleSpinBox::down-arrow {{
width: 8px; height: 8px;
}}
QComboBox::drop-down {{
subcontrol-origin: padding;
subcontrol-position: center right;
width: 24px;
border: none;
}}
QComboBox::down-arrow {{
image: none;
border: 4px solid transparent;
border-top-color: {P["fg_muted"]};
margin-top: 4px;
width: 0;
height: 0;
}}
QComboBox QAbstractItemView {{
background-color: {ELEVATED};
color: {P["fg"]};
border: 1px solid {P["border"]};
border-radius: {R["sm"]}px;
padding: {S["xs"]}px;
selection-background-color: {P["accent"]};
selection-color: {P["bg"]};
}}
/* UMAP search inputs sit directly on a raised gray card. Use the adjacent
theme surface instead of a native/base palette, which rendered these three
controls black on some Linux Qt styles. */
QWidget#UmapHyperparamControls QLineEdit,
QWidget#UmapHyperparamControls QSpinBox,
QWidget#UmapHyperparamControls QDoubleSpinBox,
QWidget#UmapHyperparamControls QComboBox {{
background-color: {P["surface_hi"]};
color: {P["fg"]};
}}
/* -----------------------------------------------------------------
* Checkboxes + toggles
* ----------------------------------------------------------------- */
QCheckBox {{
color: {P["fg"]};
background: transparent;
spacing: {S["sm"]}px;
padding: 2px 0px;
}}
QCheckBox::indicator {{
width: 16px;
height: 16px;
border: 1px solid {P["border"]};
border-radius: {R["sm"]}px;
background: {P["surface_alt"]};
}}
QCheckBox::indicator:hover {{
border-color: {P["accent"]};
}}
QCheckBox::indicator:checked {{
background: {P["accent"]};
border-color: {P["accent"]};
image: none;
}}
QCheckBox::indicator:disabled {{
background: {P["surface"]};
border-color: {P["border_soft"]};
}}
QRadioButton {{
color: {P["fg"]};
background: transparent;
spacing: {S["sm"]}px;
}}
QRadioButton::indicator {{
width: 16px;
height: 16px;
border: 1px solid {P["border"]};
border-radius: 8px;
background: {P["surface_alt"]};
}}
QRadioButton::indicator:checked {{
background: {P["accent"]};
border-color: {P["accent"]};
}}
/* -----------------------------------------------------------------
* Scrollbars
* ----------------------------------------------------------------- */
QScrollBar:vertical {{
background: {TROUGH};
width: 10px;
margin: 0px;
border: none;
}}
QScrollBar::handle:vertical {{
background: {P["border"]};
border-radius: 5px;
min-height: 30px;
}}
QScrollBar::handle:vertical:hover {{
background: {P["accent"]};
}}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
background: transparent; height: 0px;
}}
QScrollBar:horizontal {{
background: {TROUGH};
height: 10px;
margin: 0px;
border: none;
}}
QScrollBar::handle:horizontal {{
background: {P["border"]};
border-radius: 5px;
min-width: 30px;
}}
QScrollBar::handle:horizontal:hover {{
background: {P["accent"]};
}}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
background: transparent; width: 0px;
}}
/* -----------------------------------------------------------------
* Progress bar
* ----------------------------------------------------------------- */
QProgressBar {{
background: {P["surface"]};
border: none;
border-radius: 4px;
text-align: center;
color: {P["fg_muted"]};
height: 8px;
max-height: 8px;
}}
QProgressBar::chunk {{
background-color: {P["accent"]};
border-radius: 4px;
}}
QProgressBar#UsageBar {{
/* The System card already paints surface_alt. A second translucent fill
here compounds the page opacity and makes each track a darker slab. */
background: transparent;
height: 6px;
max-height: 6px;
}}
QProgressBar#UsageBarWarn, QProgressBar#UsageBarError {{
background: transparent;
height: 6px;
max-height: 6px;
}}
QProgressBar#UsageBar::chunk {{
background: {P["accent"]};
border-radius: 3px;
}}
QProgressBar#UsageBarWarn::chunk {{
background: {P["warning"]};
}}
QProgressBar#UsageBarError::chunk {{
background: {P["error"]};
}}
/* -----------------------------------------------------------------
* Splitter handle
* ----------------------------------------------------------------- */
QSplitter::handle {{
background: {P["border_soft"]};
}}
QSplitter::handle:horizontal {{
width: 1px;
}}
QSplitter::handle:vertical {{
height: 1px;
}}
QSplitter::handle:hover {{
background: {P["accent"]};
}}
/* -----------------------------------------------------------------
* Tooltip
* ----------------------------------------------------------------- */
QToolTip {{
background-color: {ELEVATED};
color: {P["fg"]};
border: 1px solid {P["border"]};
border-radius: {R["sm"]}px;
padding: {S["xs"]}px {S["sm"]}px;
font-size: {F["small"]}px;
}}
/* -----------------------------------------------------------------
* AI Console chat bubbles (legacy standalone panel)
* ----------------------------------------------------------------- */
QLabel#ChatBubbleUser {{
background-color: {P["accent_soft"]};
color: {P["fg"]};
border: 1px solid {P["accent_lo"]};
border-radius: {R["md"]}px;
padding: {S["sm"]}px {S["md"]}px;
font-size: {F["body"]}px;
}}
QLabel#ChatBubbleAssistant {{
background-color: {P["surface_alt"]};
color: {P["fg"]};
border: 1px solid {P["border_soft"]};
border-radius: {R["md"]}px;
padding: {S["sm"]}px {S["md"]}px;
font-size: {F["body"]}px;
}}
/* -----------------------------------------------------------------
* Tabs — Classifier Evaluation, Run History, and anything else
* -----------------------------------------------------------------
* There was no generic tab styling, so Qt's own drew a flat opaque
* strip with a large dark pane under it. These follow the Home tabs:
* rounded top corners, a dark surface at the page opacity, and the
* accent blue on hover so the tab under the pointer is unambiguous.
*
* Scoped by :not() on the Home tab widget, which keeps its own rules —
* it is the one place the pane is deliberately empty because the tiles
* carry the fill.
*/
QTabWidget:!hover {{ }}
QTabWidget::pane {{
background-color: {P["surface_alt"]};
border: 1px solid {P["border_soft"]};
border-radius: {R["md"]}px;
top: -1px;
}}
QTabBar {{
background: transparent;
}}
/* THE OVERFLOW ARROWS ARE NOT OURS AND LOOK IT. Qt draws a tab bar that does
not fit with two QToolButton scrollers, and no rule in this sheet claimed
them -- so they came out as opaque boxes with white arrows on every theme.
Reported 2026-08-19: "two arrows that are visable black boxes with white
arrows. these are ugly and can be removed."
Styled rather than hidden with a width of 0: a bar that genuinely
overflows still needs a way along it, and `setUsesScrollButtons` is where
a screen decides that. This makes them belong to the theme. */
QTabBar::scroller {{
width: {S["lg"]}px;
}}
QTabBar QToolButton {{
background: transparent;
border: none;
color: {P["fg_muted"]};
}}
QTabBar QToolButton:hover {{
background: {P["surface"]};
border-radius: {R["sm"]}px;
color: {P["fg"]};
}}
QTabBar QToolButton:disabled {{
color: {P["border_soft"]};
}}
QTabBar::tab {{
background-color: {P["surface"]};
color: {P["fg_muted"]};
border: 1px solid {P["border_soft"]};
border-bottom: none;
border-top-left-radius: {R["sm"]}px;
border-top-right-radius: {R["sm"]}px;
padding: {S["xs"]}px {S["md"]}px;
margin-right: 2px;
/* Stated, not inherited. A tab strip that acquires a sheet of its
own later (Home's does) loses the blanket QWidget font-size, so
Zoom stops reaching the tab text unless the size is on the rule
that styles it. */
font-size: {F["body"]}px;
}}
QTabBar::tab:hover {{
background-color: {P["accent"]};
color: {P["bg"]};
}}
QTabBar::tab:selected {{
background-color: {P["surface_alt"]};
color: {P["accent"]};
border-bottom-color: {P["surface_alt"]};
}}
/* -----------------------------------------------------------------
* Tables — every module that shows one
* -----------------------------------------------------------------
* There was no table styling at all, so Qt's own took over: white
* header text on a flat black bar that ignored the page opacity
* because it was never a spaCR surface to begin with.
*
* Each header cell is now its own rounded dark chip with a gap beside
* it, the body carries the page opacity like every other panel, the
* grid is a light hairline, and hovering a row turns it the accent
* blue so the cell under the pointer is unambiguous.
*/
QTableView, QTableWidget, QTreeView, QTreeWidget {{
background-color: {P["surface_alt"]};
alternate-background-color: {P["surface"]};
gridline-color: {P["border_soft"]};
border: 1px solid {P["border_soft"]};
border-radius: {R["md"]}px;
selection-background-color: {P["accent"]};
selection-color: {P["bg"]};
}}
QHeaderView {{
background: transparent;
border: none;
}}
QHeaderView::section {{
background-color: {P["surface_hi"]};
color: {P["fg"]};
border: none;
/* The gap that separates one chip from the next. A right/bottom
margin rather than a border, so the surface behind shows between
them instead of a drawn line. */
margin: 0px 2px 2px 0px;
padding: {S["xs"]}px {S["sm"]}px;
border-radius: {R["sm"]}px;
font-weight: 600;
}}
QHeaderView::section:hover {{
background-color: {P["accent"]};
color: {P["bg"]};
}}
QTableView::item, QTableWidget::item,
QTreeView::item, QTreeWidget::item {{
padding: {S["xs"]}px {S["sm"]}px;
border: none;
}}
QTableView::item:hover, QTableWidget::item:hover,
QTreeView::item:hover, QTreeWidget::item:hover {{
background-color: {P["accent"]};
color: {P["bg"]};
}}
/* SELECTION. There was a `:hover` rule and no `:selected` one, so every
multi-select view in the app fell through to Qt's own selection colours
-- which assume a light background and paint BLACK text. On the dark
theme the chosen rows were the only unreadable thing on screen, and in
the SQL column picker the selection IS the state of the dialog: invisible
selection means no way to tell what you are about to query.
`QListWidget` is named explicitly because it is not a `QTableView` and
nothing above covers it. */
QListView::item:selected, QListWidget::item:selected,
QTableView::item:selected, QTableWidget::item:selected,
QTreeView::item:selected, QTreeWidget::item:selected {{
background-color: {P["accent"]};
color: {SELECTION_INK};
}}
/* Kept readable when the view loses focus. Qt dims the selection to a grey
that is close enough to the surface on the dark themes to read as
unselected, which is how a picked column disappears the moment the user
clicks the OK button. */
QListView::item:selected:!active, QListWidget::item:selected:!active,
QTableView::item:selected:!active, QTableWidget::item:selected:!active,
QTreeView::item:selected:!active, QTreeWidget::item:selected:!active {{
background-color: {P["accent"]};
color: {SELECTION_INK};
}}
/* The empty square where the two headers meet. Left unstyled it is the
one opaque corner in an otherwise translucent table. */
QTableCornerButton::section {{
background-color: {P["surface_hi"]};
border: none;
border-radius: {R["sm"]}px;
}}
/* -----------------------------------------------------------------
* Help search results (422)
* ----------------------------------------------------------------- */
/* The search overlay uses a fixed 80% background independently of page
opacity. Its text stays fully opaque; the list and note remain transparent
so only the enclosing frame paints the background. */
QFrame#HelpSearchResults {{
background-color: {css_color(base["surface_hi"], 0.8)};
border: 1px solid {P["border"]};
border-radius: {R["md"]}px;
}}
QLabel#HelpSearchNote {{
background-color: transparent;
color: {P["fg"]};
padding: {S["xs"]}px {S["sm"]}px;
}}
QListWidget#HelpSearchResultList {{
background-color: transparent;
border: none;
}}
QListWidget#HelpSearchResultList::item {{
background-color: transparent;
color: {P["fg"]};
padding: {S["xs"]}px {S["sm"]}px;
}}
/* -----------------------------------------------------------------
* Merged Console (pipeline stdout + AI chat)
* ----------------------------------------------------------------- */
/* The panel is just a transparent container: the rounded box is the
ConsoleBox frame (wrapping the scroll), and the AI chat input sits UNDER it
as its own edge-aligned row. */
QWidget#ConsolePanel {{
background-color: transparent;
border: none;
}}
/* The console box KEEPS its dark surface, at the page opacity. Making it
transparent (tried, reverted) left a rounded outline floating on the opaque
container behind it — the fill is what makes it read as a console. What has
to go is that container, which `_clear_page_surfaces` now tags. */
QFrame#ConsoleBox {{
background-color: {P["surface_alt"]};
border: 1px solid {P["border_soft"]};
border-radius: {R["md"]}px;
}}
/* A console that sits inside a Card (every module screen since item 515)
is ON a panel already. Its box then paints nothing, so the console reads
as the one panel the settings categories are, not two translucent greys
stacked. Two IDs outrank the one in either ConsoleBox rule. */
QFrame#Card QFrame#ConsoleBox {{
background: transparent;
border: none;
}}
/* The AI chat text box under the console — its own rounded field, edges flush
with the console + system boxes. */
QPlainTextEdit#ConsoleChatInput, QTextEdit#ConsoleChatInput {{
background-color: {P["surface_alt"]};
border: 1px solid {P["border_soft"]};
border-radius: {R["md"]}px;
padding: {S["sm"]}px {S["md"]}px;
color: {P["fg"]};
}}
/* Transparent so the box's rounded surface shows through at the corners
(a solid child background would square them off). */
QWidget#ConsoleHolder {{
background-color: transparent;
}}
QScrollArea#ConsoleScroll {{
background-color: transparent;
border: none;
}}
QFrame#ConsoleTopicBar {{
background-color: {P["surface_hi"]};
border-top: 1px solid {P["border_soft"]};
border-bottom: 1px solid {P["border_soft"]};
}}
QLabel#ConsoleTopicLabel {{
color: {P["fg_muted"]};
font-size: {F["small"]}px;
font-weight: 600;
letter-spacing: 0.4px;
background: transparent;
}}
QPlainTextEdit#ConsoleStdoutBlock {{
color: {P["fg"]};
background-color: {P["surface_alt"]};
border: none;
font-family: "Open Sans", "Segoe UI", "Helvetica Neue", sans-serif;
font-weight: 300;
font-size: {F["small"]}px;
padding: {S["sm"]}px {S["md"]}px;
}}
QPlainTextEdit#ConsoleStdoutBlockError {{
color: {P["error"]};
background-color: {P["surface_alt"]};
border: none;
font-family: "Open Sans", "Segoe UI", "Helvetica Neue", sans-serif;
font-weight: 300;
font-size: {F["small"]}px;
padding: {S["sm"]}px {S["md"]}px;
}}
QFrame#ConsoleBubbleUser {{
background-color: #163b28; /* dark green */
border: none;
border-top: 1px solid #2a6a48;
border-bottom: 1px solid #2a6a48;
border-radius: 0px;
}}
QFrame#ConsoleBubbleAI {{
background-color: {P["accent_soft"]}; /* dark blue */
border: none;
border-top: 1px solid {P["accent_lo"]};
border-bottom: 1px solid {P["accent_lo"]};
border-radius: 0px;
}}
QFrame#ConsoleInputBar {{
background-color: {P["surface"]};
border-top: 1px solid {P["border_soft"]};
}}
/* -----------------------------------------------------------------
* Section — collapsible dropdown (custom widget)
* ----------------------------------------------------------------- */
QFrame#SectionCard {{
background-color: {P["surface"]};
border: 1px solid {P["border_soft"]};
border-radius: {R["md"]}px;
margin-bottom: {S["sm"]}px;
}}
/* THE RESTING HEADING IS THE FOREGROUND (198). It was `fg_muted` at rest
and `fg` only on hover or when open -- backwards, because the
unhighlighted state is the one a user READS: on a screen with sixteen
folded categories at most one is open and the rest are what they are
scanning to decide where to go. Dimming them says "secondary" about the
only thing on the page that is not.
The highlight is still visible: hover and checked keep `surface_alt`
behind them, and checked keeps its underline. What stopped distinguishing
the states is the text going away. */
QToolButton#SectionHeader {{
background: transparent;
color: {P["fg"]};
border: none;
border-radius: {R["md"]}px;
padding: {S["sm"]}px {S["md"]}px;
text-align: left;
font-size: {F["small"]}px;
font-weight: 600;
letter-spacing: 0.6px;
}}
QToolButton#SectionHeader:hover {{
color: {P["fg"]};
background: {P["surface_alt"]};
}}
QToolButton#SectionHeader:checked {{
color: {P["fg"]};
background: {P["surface_alt"]};
border-bottom: 1px solid {P["border_soft"]};
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}}
QWidget#SectionBody {{
background-color: transparent;
border-bottom-left-radius: {R["md"]}px;
border-bottom-right-radius: {R["md"]}px;
}}
/* The same maturity colours used by Home, carried into every module's
settings. Labels keep the theme's readable text ink; the coloured rule is
the maturity signal, so alpha cyan remains legible on the light theme. */
{SECTION_STAGE_RULES}
{FOLD_STAGE_RULES}
QPushButton#FoldButton {{
border: 1px solid transparent;
border-radius: 6px;
padding: 0px;
}}
/* -----------------------------------------------------------------
* Group box (used by settings sections)
* ----------------------------------------------------------------- */
QGroupBox {{
background: transparent;
border: 1px solid {P["border_soft"]};
border-radius: {R["md"]}px;
margin-top: {S["md"]}px;
padding: {S["md"]}px {S["sm"]}px {S["sm"]}px;
color: {P["fg_muted"]};
font-weight: 600;
font-size: {F["small"]}px;
text-transform: uppercase;
}}
QGroupBox::title {{
subcontrol-origin: margin;
subcontrol-position: top left;
left: {S["md"]}px;
top: -{S["xs"]}px;
padding: 0px {S["xs"]}px;
/* The title is text on its owning container, not a separate black notch.
Transparency keeps it matched when the same group box is placed on a
section card, popup canvas or preview surface. */
background: transparent;
}}
/* -----------------------------------------------------------------
* Status bar
* ----------------------------------------------------------------- */
QStatusBar {{
background: {P["surface"]};
color: {P["fg_muted"]};
border-top: 1px solid {P["border_soft"]};
font-size: {F["small"]}px;
padding: 0px {S["sm"]}px;
}}
/* -----------------------------------------------------------------
* Per-row widgets built many times per screen: setting chips, the
* list footer, card bodies and resource-meter rows. Ruled here once
* rather than sheeted per instance, so a screen build polishes them
* against the sheet it already carries.
* ----------------------------------------------------------------- */
QFrame#SettingChip {{
background: {base["accent_soft"]};
border: 1px solid {base["border"]};
border-radius: 9px;
}}
QFrame#SettingChip QLabel#SettingChipText {{
color: {base["fg"]};
background: transparent;
}}
QToolButton#SettingListFooter {{
color: {base["accent"]};
background: transparent;
border: none;
padding: 0px;
text-align: left;
}}
QWidget#CardBody, QWidget#UsageBarRow,
QWidget#UsageBarRow QProgressBar, QLabel#CategoryHintStrip {{
background: transparent;
}}
QSplitter#ConsoleSplit::handle:vertical {{
background: transparent;
border: none;
}}
QSplitter#ConsoleSplit::handle:vertical:hover {{
background: transparent;
border-top: 1px solid {base["button_accent"]};
}}
{GLASS_LAYER}{WIDGET_QSS}{CLOSE_MARK_RULES}
"""
#: The close mark. U+2715 MULTIPLICATION X is a full-height stroked X.
#: `×` (U+00D7 MULTIPLICATION SIGN) is a *maths operator* drawn at x-height,
#: which is why the marks it replaces read as small however large the font
#: was set.
CLOSE_MARK = "✕"
#: How much larger than body text the mark is drawn. "A large X" was the
#: ask, and body text is what the tab title beside it uses -- so the mark
#: reads as larger than the title it sits next to, and as a great deal
#: larger than the 16 px pixmap Qt draws on a closable tab.
CLOSE_MARK_SCALE = 1.15
#: Breathing room around the glyph inside its square, in px. Also what
#: keeps the mark from touching the tab title on its left.
CLOSE_MARK_PAD_PX = 8
#: The smallest square the mark stays clickable inside. Qt's own tab close
#: button is 16 px, so this is also the floor that keeps a *larger glyph*
#: from arriving with a *smaller target*.
CLOSE_MARK_HIT_PX = 22
#: Dynamic property that puts a widget under the shared close-mark rules.
#: Set it through :func:`apply_close_mark`; :func:`close_mark_rules` keys on
#: it, and a sweep for close marks keys on it too.
CLOSE_MARK_PROPERTY = "spacrCloseMark"
[docs]
def close_mark_colours(theme: str = "dark") -> Dict[str, str]:
"""Return normal, hover, and disabled close-mark colours for a theme."""
P = palette_for(theme)
return {"rest": P["fg"], "hover": P["error"], "disabled": P["fg_dim"]}
[docs]
def close_mark_font_px(body_px: Optional[int] = None) -> int:
"""Return the close-mark font size in pixels.
:param body_px: Resolved body-text size. ``None`` uses :func:`font_px`.
"""
base = font_px("body") if body_px is None else int(body_px)
return max(_QT_MIN_FONT_PX, int(round(base * CLOSE_MARK_SCALE)))
[docs]
def close_mark_rules(theme: str = "dark",
body_px: Optional[int] = None) -> str:
"""Return the shared Qt style-sheet rules for close marks."""
ink = close_mark_colours(theme)
size = close_mark_font_px(body_px)
prop = CLOSE_MARK_PROPERTY
return f"""
/* -----------------------------------------------------------------
* The one close mark
* -----------------------------------------------------------------
* Theme ink at rest, red under the pointer, everywhere in the app.
* Keyed on a property rather than an object name so a new closable
* thing joins by asking for the mark, not by editing this sheet.
*/
*[{prop}="true"] {{
color: {ink["rest"]};
background: transparent;
border: none;
padding: 0px;
font-size: {size}px;
font-weight: 400;
}}
*[{prop}="true"]:hover {{
color: {ink["hover"]};
background: transparent;
border: none;
}}
*[{prop}="true"]:pressed {{
color: {ink["hover"]};
background: transparent;
border: none;
}}
*[{prop}="true"]:disabled {{
color: {ink["disabled"]};
background: transparent;
border: none;
}}
"""
[docs]
def repolish(widget) -> None:
"""Reapply Qt styling after a widget property changes.
:param widget: the widget to unpolish, polish and update.
"""
style = widget.style()
if style is not None:
style.unpolish(widget)
style.polish(widget)
widget.update()
[docs]
def close_mark_side(widget=None, body_px: Optional[int] = None) -> int:
"""Return the required side length for a close-mark hit target.
The result accounts for the rendered glyph, current interface scale, and
:data:`CLOSE_MARK_HIT_PX` minimum. The glyph's ink box is measured once
per font: ``tightBoundingRect`` rasterises the glyph, and a module screen
re-measures each of its close marks at every style change.
"""
from PySide6.QtGui import QFont, QFontMetrics
font = QFont(widget.font()) if widget is not None else QFont()
font.setPixelSize(max(font.pixelSize(), close_mark_font_px(body_px)))
key = font.key()
ink = _CLOSE_MARK_INK.get(key)
if ink is None:
rect = QFontMetrics(font).tightBoundingRect(CLOSE_MARK)
ink = (rect.width(), rect.height())
if len(_CLOSE_MARK_INK) > 64:
_CLOSE_MARK_INK.clear()
_CLOSE_MARK_INK[key] = ink
return max(CLOSE_MARK_HIT_PX,
ink[0] + CLOSE_MARK_PAD_PX,
ink[1] + CLOSE_MARK_PAD_PX)
_CLOSE_MARK_INK: dict = {}
[docs]
def apply_close_mark(button, *, tooltip: Optional[str] = None,
body_px: Optional[int] = None):
"""Apply the shared close-mark glyph, styling, and hit-target size.
:param button: Qt button to configure.
:param tooltip: Replacement tooltip. ``None`` preserves the existing
tooltip.
:param body_px: Optional resolved body-text size.
:returns: The configured button.
"""
button.setText(CLOSE_MARK)
button.setProperty(CLOSE_MARK_PROPERTY, True)
repolish(button)
size_close_mark(button, body_px)
if getattr(button, "_spacr_close_mark_resizer", None) is None:
resizer = _CloseMarkResizer(button, body_px)
button._spacr_close_mark_resizer = resizer
button.installEventFilter(resizer)
button.setCursor(Qt.PointingHandCursor)
set_auto_raise = getattr(button, "setAutoRaise", None)
if callable(set_auto_raise):
set_auto_raise(True)
set_flat = getattr(button, "setFlat", None)
if callable(set_flat):
set_flat(True)
if tooltip is not None:
button.setToolTip(tooltip)
return button
[docs]
def size_close_mark(button, body_px: Optional[int] = None) -> None:
"""Resize a close-mark button for its current font and interface scale.
THE FLOORS ARE CONVERTED, and that is the whole of the GUI scale in
here. ``minimumWidth`` answers in 100 % units (the scaling layer in
:mod:`spacr.qt.gui_scale` remembers what the code asked for) while the
glyph is measured in the pixels actually being drawn, so comparing them
raw made the box 12 px wider than the mark at 50 % and left the chrome
shifted when the scale came back.
:param button: the close-mark button to give a fixed size.
"""
from .gui_scale import scale_int
side = close_mark_side(button, body_px)
hint = button.sizeHint()
floor_h = scale_int(button.minimumHeight())
floor_w = scale_int(button.minimumWidth())
height = max(side, floor_h, hint.height())
width = max(side, floor_w, min(hint.width(), height))
button.setFixedSize(width, height)
class _CloseMarkResizer(QObject):
"""Re-measure a close mark when the style hands it a new font.
The box is FIXED so a close mark cannot balloon into the row beside it,
which means a live Zoom change -- the sheet is rebuilt, the glyph grows,
the box does not -- would clip the X. Qt sends ``FontChange`` when the
sheet's font-size reaches the widget; that is the moment to re-measure.
"""
def __init__(self, button, body_px: Optional[int] = None):
"""Keep one close mark's box fixed across font and style changes.
:param button: the close mark to re-measure; also the QObject
parent.
:param body_px: the body text size to size against, or ``None`` to
read the current one at each change. Pin it only for a mark
that must NOT follow the app's Zoom -- everything else wants
``None``, which is what makes a live Zoom change re-fix the box
instead of clipping the X.
"""
super().__init__(button)
self._body_px = body_px
def eventFilter(self, obj, event):
"""Re-fix the box whenever the style or the font under it moves."""
if event.type() in (QEvent.FontChange, QEvent.StyleChange):
try:
size_close_mark(obj, self._body_px)
except RuntimeError:
pass
return False
[docs]
def is_close_mark(widget) -> bool:
"""Return whether a widget uses the shared close-mark styling.
:param widget: the widget to test; None gives False.
"""
return bool(widget is not None and widget.property(CLOSE_MARK_PROPERTY))
class _CloseMarkWatcher(QObject):
"""Re-mark a tab bar whenever Qt hands it a new close button."""
def __init__(self, bar, tooltip: Optional[str] = None):
"""Re-mark a tab bar's close buttons as Qt creates them.
:param bar: the tab bar to watch; also the QObject parent.
:param tooltip: the tooltip put on each close button, or ``None``
for none. Applied to every button this watcher marks, INCLUDING
ONES CREATED LATER -- which is the reason the watcher exists:
Qt makes a new close button per tab, and a tooltip set once at
setup covers only the tabs open at the time.
"""
super().__init__(bar)
self._bar = weakref.ref(bar)
self._tooltip = tooltip
self._pending = False
def eventFilter(self, obj, event):
"""Schedule a re-mark for the child Qt has just added."""
ref = getattr(self, "_bar", None)
bar = ref() if ref is not None else None
if (bar is not None and obj is bar
and event.type() == QEvent.ChildAdded
and not getattr(self, "_pending", True)):
self._pending = True
QTimer.singleShot(0, self._sweep)
return False
def _sweep(self) -> None:
"""Mark whatever arrived, once the bar has finished wiring it up."""
self._pending = False
ref = getattr(self, "_bar", None)
bar = ref() if ref is not None else None
if bar is None:
return
try:
mark_tab_bar(bar, self._tooltip)
except RuntimeError:
pass
def _request_tab_close(bar, mark, side) -> None:
"""Ask ``bar`` to close whichever tab ``mark`` is sitting on right now.
The mark is looked up rather than remembered: closing an earlier tab
renumbers every later one, and a remembered index would then close the
wrong page.
"""
for index in range(bar.count()):
if bar.tabButton(index, side) is mark:
bar.tabCloseRequested.emit(index)
return
[docs]
def mark_tab_bar(bar, tooltip: Optional[str] = None) -> int:
"""Replace existing tab close buttons with the shared close mark.
Tabs without a close button remain unchanged, and hidden buttons remain
hidden.
:param bar: the :class:`QTabBar` whose close buttons are replaced.
:returns: Number of close marks installed.
"""
from PySide6.QtWidgets import QTabBar, QToolButton
if getattr(bar, "_spacr_marking_tabs", False):
return 0
bar._spacr_marking_tabs = True
replaced = 0
try:
for index in range(bar.count()):
for side in (QTabBar.RightSide, QTabBar.LeftSide):
existing = bar.tabButton(index, side)
if existing is None or is_close_mark(existing):
continue
mark = QToolButton(bar)
text = tooltip if tooltip is not None else existing.toolTip()
apply_close_mark(mark, tooltip=text or None)
mark.clicked.connect(
lambda *_a, b=bar, m=mark, s=side:
_request_tab_close(b, m, s))
hidden = existing.isHidden()
bar.setTabButton(index, side, mark)
if hidden:
mark.hide()
replaced += 1
finally:
bar._spacr_marking_tabs = False
return replaced
[docs]
def install_close_marks(root, *, tooltip: Optional[str] = None) -> int:
"""Install shared close marks on closable tabs below ``root``.
``root`` may be a tab widget, tab bar, or containing widget. Event
filters also style close buttons added later. Repeated calls are
idempotent.
:param root: a :class:`QTabWidget`, a :class:`QTabBar`, or any widget whose
child tab widgets and tab bars are marked.
:returns: Number of close marks installed during this call.
"""
from PySide6.QtWidgets import QTabBar, QTabWidget
if isinstance(root, QTabWidget):
bars = [root.tabBar()]
elif isinstance(root, QTabBar):
bars = [root]
else:
bars = [widget.tabBar() for widget in root.findChildren(QTabWidget)]
bars.extend(root.findChildren(QTabBar))
installed = 0
for bar in {id(bar): bar for bar in bars}.values():
if getattr(bar, "_spacr_close_mark_watcher", None) is None:
watcher = _CloseMarkWatcher(bar, tooltip)
bar._spacr_close_mark_watcher = watcher
bar.installEventFilter(watcher)
installed += mark_tab_bar(bar, tooltip)
return installed