spacr.qt.theme

Provide palettes, geometry tokens, and QSS for the spaCR Qt interface.

Use active_palette() for colors shown by a live widget and stylesheet() for the application stylesheet. THEMES contains the selectable palettes "dark", "light", "cell", "glass", "high_contrast" and the night and data-art presets of 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 active_palette(), or DARK_PALETTE only when dark colors are explicitly required.

Cell and Glass are IMAGE_THEMES. Their panels use translucent scrims so the backdrop remains visible without sacrificing text contrast. contrast_failures() validates roles painted on scrims, while image_contrast_failures() validates roles painted directly over image content. solve_scrim_alpha() balances those contrast constraints against MIN_PICTURE_CONTRAST, and scrim_report() exposes the result.

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 THEMES and the light/dark handling are untouched; it is process state and is never persisted.

Functions

__getattr__(name)

Serve the deprecated PALETTE alias (PEP 562).

active_page_colour(→ str)

page_colour() for the theme that is on screen right now.

active_palette(→ dict)

The palette for the theme that is on screen right now.

advance_spaceout_drift(→ float)

Advance the spaceout clock and return the resulting hue rotation.

apply_close_mark(button, *[, tooltip, body_px])

Apply the shared close-mark glyph, styling, and hit-target size.

apply_qpalette(→ None)

Apply the palette to the QApplication so native controls (menu

apply_stylesheet_per_window(→ int)

Install sheet on every top-level window instead of on app.

block_surface(→ str)

pane_surface() for a registered QSS block: None IS the scrim.

button_accent_text(→ str)

The colour for TEXT drawn in the button accent straight on the page.

clear_container_surfaces(→ int)

Tag every layout container under root so it paints nothing.

clear_widget_qss_overlays(→ int)

Remove screen-local late-QSS suffixes before a global theme rebuild.

close_mark_button([parent, tooltip, body_px])

Create a standalone close mark as a flat QToolButton.

close_mark_colours(→ Dict[str, str])

Return normal, hover, and disabled close-mark colours for a theme.

close_mark_font_px(→ int)

Return the close-mark font size in pixels.

close_mark_rules(→ str)

Return the shared Qt style-sheet rules for close marks.

close_mark_side(→ int)

Return the required side length for a close-mark hit target.

composite(→ str)

Alpha-composite top at alpha over under, as hex.

contrast_failures(→ List[str])

Human-readable description of every rule theme fails.

contrast_ratio(→ float)

WCAG contrast ratio between two colours — 1.0 (same) to 21.0.

contrast_report(→ List[dict])

Measured contrast for every rule in CONTRAST_RULES.

css_color(→ str)

Render a colour for QSS — plain hex, or rgba() when translucent.

disable_spaceout(→ None)

Disable process-local spaceout rendering and reset its clock.

dock_colour(→ str)

The left dock's background. Never translucent, in any theme.

effective_surface(→ str)

The colour a surface role actually presents to the eye.

enable_spaceout(→ None)

Enable process-local spaceout rendering.

ensure_widget_qss_applied(→ bool)

Install late registered blocks on root without restyling the app.

field_chrome(→ Dict[str, object])

Return theme colors and geometry for faded field containers.

field_fade_alpha(→ float)

Alpha of a field's container at fraction t across its width.

field_fade_profile([stops])

The sampled ramp as ((t, alpha), ...), left edge first.

font_px(→ int)

Return a font size in px with the user's Zoom preference applied.

glass_material(→ str)

Return a neutral, layered QSS brush that suggests optical depth.

hold_the_colour_scheme(→ bool)

Tell the platform which scheme spaCR draws in, so it cannot differ.

image_contrast_failures(→ List[str])

Every rule theme fails over a wallpaper colour under.

image_contrast_report(→ List[dict])

Measured contrast for every rule, judged over a real image colour.

install_close_marks(→ int)

Install shared close marks on closable tabs below root.

is_close_mark(→ bool)

Return whether a widget uses the shared close-mark styling.

is_surface(→ bool)

Whether widget was declared a page surface by mark_surface().

legible_scrim_floor(→ float)

Thinnest scrim for role that text is still readable over.

lightness(→ float)

CIE L* of color, 0 (black) to 100 (white).

load_widget_qss_registrars(→ Tuple[str, ...])

Import WIDGET_QSS_MODULES so their blocks are registered.

make_transparent(→ None)

Stop widgets painting a background of their own.

mark_as_a_sheet_target(→ None)

Have widget carry the application sheet in its own right.

mark_surface(→ None)

Declare that widgets ARE the page surface, not passengers on one.

mark_tab_bar(→ int)

Replace existing tab close buttons with the shared close mark.

max_background_luma(→ float)

Brightest a background image may be before theme fails AA.

menu_bar_background(→ str)

The QSS colour the menu bar and its corner chrome both paint.

page_colour(→ str)

The flat colour the page is painted with under theme.

page_separation_failures(→ List[str])

Human-readable description of every separation theme fails.

page_separation_report(→ List[dict])

How far each resting panel role sits from the page in theme.

page_tabs_qss(→ str)

Home's tab treatment, for a tab strip that IS the page.

paint_panel(→ None)

Draw a rounded, translucent panel filling widget.

palette_for(→ dict)

Return the palette dict for theme.

pane_alpha(→ float)

The alpha a user-controlled page surface is actually painted at.

pane_alpha_floor(→ float)

Thinnest the Home pane may be painted and stay readable.

pane_surface(→ str)

A page-surface colour, already carrying the user's page opacity.

panel_alpha(→ float)

Apply the page-opacity preference to a shared UI surface role.

panel_qcolor(→ PySide6.QtGui.QColor)

pane_surface() as a QColor, alpha included.

picture_contrast(→ float)

How much of the wallpaper survives role at alpha.

present_scrim_ceiling(→ float)

Thickest scrim for role that the picture still reads through.

preserve_widget_qss_overlay(→ str)

Return stylesheet with root's owned late-QSS suffix intact.

register_widget_qss(name, fn, *[, replace])

Register a QSS block appended to every generated stylesheet.

registered_widget_qss(→ str)

Render every registered block into one QSS fragment.

relative_luminance(→ float)

WCAG relative luminance of a #rrggbb colour, in [0, 1].

repolish(→ None)

Reapply Qt styling after a widget property changes.

rim_colour(→ str)

The meaningful hairline on outlined tiles — the theme's own ink.

scheme_of(→ str)

"light" or "dark": which way theme draws its text.

scrim_alpha(→ float)

Opacity of surface role in theme. 1.0 unless translucent.

scrim_failures(→ List[str])

Every role of theme that cannot be both legible and see-through.

scrim_report(→ List[dict])

Both bounds, the solved alpha and what it buys, for every role.

scrim_under(→ str)

Brightest colour theme's wallpaper can put behind a panel.

selection_ink(→ str)

Text colour for a row selected with the accent behind it.

set_a_sheeted_widgets_own_rule(→ None)

Replace widget's own QSS without losing the sheet it carries.

set_spaceout_drift_seconds(→ None)

Set the spaceout animation clock, clamped to zero or greater.

set_widget_qss_context(→ None)

Record the exact live preference inputs for late screen blocks.

size_close_mark(→ None)

Resize a close-mark button for its current font and interface scale.

solve_scrim_alpha(→ float)

The opacity role should be painted at in theme.

spaceout_drift(→ float)

Return the continuous spaceout hue rotation in degrees.

spaceout_drift_seconds(→ float)

Return the elapsed spaceout animation time in seconds.

spaceout_drift_step(→ float)

Return spaceout_drift() quantised to its solved palette grid.

spaceout_enabled(→ bool)

Return whether spaceout rendering is enabled for this process.

spaceout_palette(→ dict)

Re-hue a theme palette while preserving accessible luminance.

splash_dim_alpha(→ int)

The lowest alpha at which ink still reads target over bg.

stage_hover(→ str)

Hover colour for stage; unknown stages read as stable.

stylesheet(→ str)

Return the QSS string that styles every custom widget in the app.

system_colour_scheme(→ Optional[str])

What the operating system's own light/dark setting is, if Qt knows.

take_the_scroll_arrows_off(→ int)

Disable overflow buttons for every tab bar below root.

unregister_widget_qss(→ bool)

Drop a registered block. True if there was one.

use_a_style_that_honours_the_palette(→ str)

Put the application on Fusion unless somebody asked for a style.

widget_qss_names(→ Tuple[str, ...])

Every registered block name, in registration order.

window_stylesheet(→ Optional[str])

The sheet apply_stylesheet_per_window() last installed.

Module Contents

spacr.qt.theme.__getattr__(name: str)[source]

Serve the deprecated PALETTE alias (PEP 562).

spacr.qt.theme.active_page_colour() → str[source]

page_colour() for the theme that is on screen right now.

Falls back to dark, like active_palette(), and for the same reason: a backdrop must never be the thing that stops a screen opening.

spacr.qt.theme.active_palette() → dict[source]

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.

spacr.qt.theme.advance_spaceout_drift(dt: float) → float[source]

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.

Parameters:

dt – seconds to add to the spaceout clock; ignored when not positive or when spaceout is off.

spacr.qt.theme.apply_close_mark(button, *, tooltip: str | None = None, body_px: int | None = None)[source]

Apply the shared close-mark glyph, styling, and hit-target size.

Parameters:
  • button – Qt button to configure.

  • tooltip – Replacement tooltip. None preserves the existing tooltip.

  • body_px – Optional resolved body-text size.

Returns:

The configured button.

spacr.qt.theme.apply_qpalette(app: PySide6.QtWidgets.QApplication, theme: str = 'dark', *, follow_system: bool = False) → None[source]

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 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.

Parameters:
  • app – the running QApplication.

  • theme – one of THEMES; unknown values fall back to dark.

  • follow_system – release the scheme to the operating system instead of pinning it; the explicit “Follow system” choice.

spacr.qt.theme.apply_stylesheet_per_window(app, sheet: str) → int[source]

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.

Parameters:
  • 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.

  • sheet – the complete application stylesheet, as 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 spacr.qt.widgets.section.Section._detach_body_while_hidden().

spacr.qt.theme.block_surface(role: str = 'surface_alt', theme: str | None = None, opacity: float | None = None) → str[source]

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 register_widget_qss() is handed the opacity 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 panel_alpha().

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 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 %.

Parameters:
  • role – a palette key, normally surface/surface_alt/tile.

  • theme – theme name; None resolves the effective one.

  • 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.

spacr.qt.theme.button_accent_text(palette: dict | None = None) → str[source]

The colour for TEXT drawn in the button accent straight on the page.

button_accent is the same blue on every theme (see 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.

Parameters:

palette – a palette carrying bg, fg and the accent roles; active_palette() when omitted.

Returns:

a hex colour.

spacr.qt.theme.clear_container_surfaces(root) → int[source]

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 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 mark_surface().

Parameters:

root – the screen (or any subtree) to sweep.

Returns:

how many widgets were tagged, which is what a test asserts on.

spacr.qt.theme.clear_widget_qss_overlays(app=None) → int[source]

Remove screen-local late-QSS suffixes before a global theme rebuild.

The rebuilt application sheet contains every block registered so far, with the new theme, opacity and font scale. Leaving an older local copy in place would give it precedence and strand the screen on the previous preference values.

Returns:

number of screen roots whose owned suffix was removed.

spacr.qt.theme.close_mark_button(parent=None, *, tooltip: str | None = None, body_px: int | None = None)[source]

Create a standalone close mark as a flat QToolButton.

spacr.qt.theme.close_mark_colours(theme: str = 'dark') → Dict[str, str][source]

Return normal, hover, and disabled close-mark colours for a theme.

spacr.qt.theme.close_mark_font_px(body_px: int | None = None) → int[source]

Return the close-mark font size in pixels.

Parameters:

body_px – Resolved body-text size. None uses font_px().

spacr.qt.theme.close_mark_rules(theme: str = 'dark', body_px: int | None = None) → str[source]

Return the shared Qt style-sheet rules for close marks.

spacr.qt.theme.close_mark_side(widget=None, body_px: int | None = None) → int[source]

Return the required side length for a close-mark hit target.

The result accounts for the rendered glyph, current interface scale, and 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.

spacr.qt.theme.composite(top: str, alpha: float, under: str = WORST_CASE_UNDER) → str[source]

Alpha-composite top at alpha over under, as hex.

Parameters:
  • top – the upper colour, a #rgb or #rrggbb colour string.

  • alpha – opacity of top, clamped to [0, 1].

spacr.qt.theme.contrast_failures(theme: str) → List[str][source]

Human-readable description of every rule theme fails.

Parameters:

theme – the theme name, as passed to palette_for().

spacr.qt.theme.contrast_ratio(a: str, b: str) → float[source]

WCAG contrast ratio between two colours — 1.0 (same) to 21.0.

Parameters:
  • a – one colour, a #rgb or #rrggbb colour string.

  • b – the other colour; the order of a and b does not matter.

spacr.qt.theme.contrast_report(theme: str) → List[dict][source]

Measured contrast for every rule in CONTRAST_RULES.

Each entry is {"fg", "bg", "fg_color", "bg_color", "ratio", "required", "passes"}. Surfaces are resolved through effective_surface(), so Space is judged on the composited scrim rather than on a colour the user never actually sees.

Parameters:

theme – the theme name, as passed to palette_for().

spacr.qt.theme.css_color(color: str, alpha: float = 1.0) → str[source]

Render a colour for QSS — plain hex, or rgba() when translucent.

Parameters:

color – the colour, a #rgb or #rrggbb colour string. Returned unchanged when alpha is 1 or more.

spacr.qt.theme.disable_spaceout() → None[source]

Disable process-local spaceout rendering and reset its clock.

spacr.qt.theme.dock_colour(theme: str = 'dark') → str[source]

The left dock’s background. Never translucent, in any theme.

The dock used to paint surface, which the image themes re-render through 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).

spacr.qt.theme.effective_surface(theme: str, role: str, under: str | None = None) → str[source]

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 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.

Parameters:
  • theme – the theme name, as passed to palette_for().

  • role – the surface role, a key of the theme’s palette.

spacr.qt.theme.enable_spaceout() → None[source]

Enable process-local spaceout rendering.

This operation is idempotent and does not modify saved preferences.

spacr.qt.theme.ensure_widget_qss_applied(*names: str, root=None) → bool[source]

Install late registered blocks on root without restyling the app.

The production application stylesheet is composed before unopened screen modules are imported.

Replacing that whole sheet for each import closes the first-paint race, but it also makes Qt parse the sheet and re-polish every widget accumulated in every cached screen.

A screen root is a QSS scope: rules installed there reach that screen and its descendants, and applying them before the root is shown preserves the same first-paint contract without touching Home or any previously opened module.

The suffix contains every registered block absent from the application sheet, in registry order, rather than only names. This keeps blocks imported by a screen’s dependencies together and means a later call can replace one complete suffix instead of stacking fragments with different preference values. names remains the caller’s documentation of the blocks it requires; omitting it is the MainWindow screen-host path.

It is a no-op with no root, no QApplication, or no spaCR sheet in force. A caller that never opted into spaCR styling is not opted in merely by constructing one of its widgets.

THE SHEET IN FORCE IS NOT app.styleSheet() ANY MORE. Per-window sheeting takes the application sheet DOWN on purpose and gives each window its own, so app.styleSheet() is empty in every production run – and this function read that as “nobody opted in” and returned before doing anything. Measured: opening one module registers four blocks (SettingsBox, ClassEditor, SettingAlphabetChip, TableChip) and NONE of the four reached the screen that had just imported them. That is the exact defect this function was written for, reintroduced by the change that made the window the sheet’s owner.

A ROOT WHOSE SHEET IS STILL OWED KEEPS THE BLOCKS AND IS NOT RESTYLED. A module screen built after the theme is in force carries no sheet until its first show, and _sheet_one_window() puts this suffix on the end of the sheet it applies then. Applying it here as well would repolish the whole screen once for the suffix and again when Qt reparents it into the stack – 829 ms and about as much again on a Regression open, for a screen nobody could see yet.

Returns:

True only when root.setStyleSheet was called.

spacr.qt.theme.field_chrome(theme: str = 'dark') → Dict[str, object][source]

Return theme colors and geometry for faded field containers.

Parameters:

theme (str, default="dark") – Theme name accepted by 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.

spacr.qt.theme.field_fade_alpha(t: float) → float[source]

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.

Parameters:

t – fraction of the way across the field, clamped to [0, 1].

spacr.qt.theme.field_fade_profile(stops: int = FIELD_FADE_STOPS)[source]

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.

spacr.qt.theme.font_px(role_or_px, scale: float | None = None) → int[source]

Return a font size in px with the user’s Zoom preference applied.

The application stylesheet scales FONT_SIZE itself (see 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.

Parameters:
  • role_or_px – a FONT_SIZE key ("body", "small" …) or a raw base pixel size.

  • scale – override the preference — used by 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.

spacr.qt.theme.glass_material(color: str, alpha: float) → str[source]

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.

Parameters:
  • color – the body colour of the glass, a #rgb or #rrggbb colour string.

  • alpha – base opacity of the body, clamped to [0, 1]; the highlight and lower edge are drawn slightly denser.

spacr.qt.theme.hold_the_colour_scheme(app, theme: str | None) → bool[source]

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 use_a_style_that_honours_the_palette() installs) still carry the colours.

Parameters:
  • app – the running application.

  • theme – a theme from THEMES, or None to follow the system again.

Returns:

True when the request reached Qt.

spacr.qt.theme.image_contrast_failures(theme: str, under: str) → List[str][source]

Every rule theme fails over a wallpaper colour under.

Parameters:
  • theme – the theme name, as passed to palette_for().

  • under – the wallpaper colour sampled behind the text, a #rgb or #rrggbb colour string.

spacr.qt.theme.image_contrast_report(theme: str, under: str) → List[dict][source]

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 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.

Parameters:
  • theme – the theme name, as passed to palette_for().

  • under – the wallpaper colour sampled behind the text, a #rgb or #rrggbb colour string.

spacr.qt.theme.install_close_marks(root, *, tooltip: str | None = None) → int[source]

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.

Parameters:

root – a QTabWidget, a QTabBar, or any widget whose child tab widgets and tab bars are marked.

Returns:

Number of close marks installed during this call.

spacr.qt.theme.is_close_mark(widget) → bool[source]

Return whether a widget uses the shared close-mark styling.

Parameters:

widget – the widget to test; None gives False.

spacr.qt.theme.is_surface(widget) → bool[source]

Whether widget was declared a page surface by mark_surface().

Parameters:

widget – the widget to test; None gives False.

spacr.qt.theme.legible_scrim_floor(theme: str, role: str, colour_role: str | None = None, under: str | None = None) → float[source]

Thinnest scrim for role that text is still readable over.

Every rule in CONTRAST_RULES that paints text on this surface must clear its WCAG minimum (times SCRIM_HEADROOM) with the surface composited over 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.

Parameters:
  • theme – name of the palette whose surface is being evaluated.

  • role – palette surface role on which the text is painted.

  • colour_role – palette entry the surface is painted with, when it differs from role — tile is painted with surface.

  • under – what is actually behind the surface, when it is not the wallpaper. 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.

spacr.qt.theme.lightness(color: str) → float[source]

CIE L* of color, 0 (black) to 100 (white).

Perceptually uniform, which relative_luminance() is not and 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.

Parameters:

color – a #rgb or #rrggbb colour string.

spacr.qt.theme.load_widget_qss_registrars() → Tuple[str, ...][source]

Import WIDGET_QSS_MODULES so their blocks are registered.

Called by the exhaustive/default stylesheet() path before it composes anything. The production preference path opts out so unopened data screens do not import their scientific dependencies merely to contribute decoration.

Idempotent, and the flag is set BEFORE the imports rather than after: several of these modules call stylesheet() while being imported, and without that ordering the first one would recurse.

One module’s failure costs that module’s rules and nothing else. A widget QSS block is decoration; it must never be the thing that stops the GUI from starting.

Returns:

the module names that imported cleanly.

spacr.qt.theme.make_transparent(*widgets) → None[source]

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.

spacr.qt.theme.mark_as_a_sheet_target(widget) → None[source]

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.

Parameters:

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.

spacr.qt.theme.mark_surface(*widgets) → None[source]

Declare that widgets ARE the page surface, not passengers on one.

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 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.

spacr.qt.theme.mark_tab_bar(bar, tooltip: str | None = None) → int[source]

Replace existing tab close buttons with the shared close mark.

Tabs without a close button remain unchanged, and hidden buttons remain hidden.

Parameters:

bar – the QTabBar whose close buttons are replaced.

Returns:

Number of close marks installed.

spacr.qt.theme.max_background_luma(theme: str) → float[source]

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 BARE_IMAGE_RULES, and it is what spacr.qt.imagery.solve_dim() expects as its target.

Parameters:

theme – the theme name, as passed to palette_for().

spacr.qt.theme.menu_bar_background(theme: str | None = None) → str[source]

The QSS colour the menu bar and its corner chrome both paint.

ONE FUNCTION FOR BOTH so they cannot drift. The bar is styled from the generated stylesheet and the window chrome is styled in spacr.qt.app with a stylesheet of its own; two hard-coded colours that have to match is one of them going stale.

Parameters:

theme – theme name; the active theme when omitted.

Returns:

a QSS colour string.

spacr.qt.theme.page_colour(theme: str = 'dark') → str[source]

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.

spacr.qt.theme.page_separation_failures(theme: str) → List[str][source]

Human-readable description of every separation theme fails.

Parameters:

theme – the theme name, as passed to palette_for().

spacr.qt.theme.page_separation_report(theme: str) → List[dict][source]

How far each resting panel role sits from the page in theme.

One entry per role in 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.

Parameters:

theme – the theme name, as passed to palette_for().

spacr.qt.theme.page_tabs_qss(object_name: str, palette: dict, opacity=None) → str[source]

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.

Parameters:
  • object_name – objectName of the QTabWidget.

  • palette – the palette handed to a registered block, including the reserved theme key.

  • opacity – the page-opacity preference, passed straight through — None here means the theme’s designed scrim, which is why this reads block_surface() and not pane_surface().

spacr.qt.theme.paint_panel(painter, widget, *, role: str = 'surface', radius: int | None = None, border: bool = True, inset: float = 0.0, theme: str | None = None, opacity: float | None = None) → None[source]

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 make_transparent() arranges.

Parameters:
  • painter – an active QPainter on widget.

  • widget – the widget being painted; its rect() is the panel.

  • role – palette key for the fill.

  • radius – corner radius in px; None uses RADIUS["md"].

  • border – draw the theme’s soft hairline around the panel.

  • inset – shrink the panel by this many px on every side, so a 1 px border lands inside the widget instead of being clipped.

spacr.qt.theme.palette_for(theme: str = 'dark') → dict[source]

Return the palette dict for theme.

theme is one of 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 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 (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 SPACEOUT_INK_ROLES.

spacr.qt.theme.pane_alpha(theme: str, opacity: float | None = None) → float[source]

The alpha a user-controlled page surface is actually painted at.

The user’s opacity (0..1), clamped up to pane_alpha_floor(). None means DEFAULT_PANE_OPACITY.

Parameters:

theme – the theme name, as passed to palette_for(). Glass scales the opacity by its designed surface scrim.

spacr.qt.theme.pane_alpha_floor(theme: str) → float[source]

Thinnest the Home pane may be painted and stay readable.

An image theme is judged against 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.

Parameters:

theme – the theme name, as passed to palette_for(). Image themes are judged over scrim_under(), others over their own bg colour.

spacr.qt.theme.pane_surface(role: str = 'surface_alt', theme: str | None = None, opacity: float | None = None) → str[source]

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 stylesheet(). Those were the gap: Home’s aside panels, the dock and the tile boxes all read 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.

Parameters:
  • role – a palette key, normally surface/surface_alt/tile.

  • theme – theme name; None resolves the effective one.

  • opacity – 0..1 override; None reads the preference.

Returns:

a QSS colour — plain hex when opaque, rgba() when not.

spacr.qt.theme.panel_alpha(theme: str, role: str, opacity: float | None = None) → float[source]

Apply the page-opacity preference to a shared UI surface role.

None preserves the theme’s designed scrim, which keeps 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.

Parameters:
  • theme – the theme name, as passed to palette_for().

  • role – the surface role; elevated is always opaque.

spacr.qt.theme.panel_qcolor(role: str = 'surface', theme: str | None = None, opacity: float | None = None) → PySide6.QtGui.QColor[source]

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.

Parameters:
  • role – palette key, normally surface/surface_alt.

  • theme – theme name; None resolves the effective one.

  • opacity – 0..1 override; None reads the preference.

spacr.qt.theme.picture_contrast(theme: str, role: str, alpha: float, colour_role: str | None = None) → float[source]

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.

Parameters:
  • theme – the theme name, as passed to palette_for().

  • role – the surface role, a key of the theme’s palette.

  • alpha – the panel’s opacity, 0 to 1.

spacr.qt.theme.present_scrim_ceiling(theme: str, role: str, colour_role: str | None = None) → float[source]

Thickest scrim for role that the picture still reads through.

The largest alpha whose picture_contrast() is still at least MIN_PICTURE_CONTRAST. Above this number the wallpaper is a ghost — which is the bug this whole solver exists to close.

Parameters:
  • theme – the theme name, as passed to palette_for().

  • role – the surface role, a key of the theme’s palette.

spacr.qt.theme.preserve_widget_qss_overlay(root, stylesheet: str) → str[source]

Return stylesheet with root’s owned late-QSS suffix intact.

A screen may legitimately replace its own base stylesheet after its late widget blocks were installed. Folding the suffix into that existing assignment avoids a second setStyleSheet call (and its palette-change cascade) while keeping the blocks available for the next paint.

Parameters:
  • root – the widget whose stored late-QSS suffix is appended; a widget without one adds nothing.

  • stylesheet – the new base stylesheet, converted to a string.

spacr.qt.theme.register_widget_qss(name: str, fn, *, replace: bool = False)[source]

Register a QSS block appended to every generated stylesheet.

Registration itself never re-applies the QApplication stylesheet. Qt re-polishes every live widget on a global setStyleSheet call; doing that once for every screen imported on demand made later module opens progressively slower. ensure_widget_qss_applied() installs the missing blocks on the new screen’s root before it can paint instead.

Parameters:
  • name – stable identifier, normally the widget’s objectName. It is what the block is reported and unregistered by; it does not appear in the QSS.

  • fn –

    fn(palette, opacity) -> str, called once per stylesheet() call.

    palette is the theme’s palette with the three surface roles (surface, surface_alt, surface_hi) already rendered through the user’s page opacity — the same values the built-in rules interpolate, so a registered block matches the app without doing the alpha maths. Two reserved non-colour keys ride along: theme (the theme name, which pane_surface(), pane_alpha() and palette_for() all want) and font_scale.

    opacity is the user’s page-opacity preference, or None for “use the theme’s designed scrim”. Pass it straight through to block_surface() / pane_alpha() / panel_alpha() rather than interpreting it: None is not 1.0, and the legibility floor is theirs to apply.

    block_surface() and not pane_surface(), which is the near-identical accessor for inline and paint-time callers. Its None means “nobody told me” and reads the live preference, so a block using it turns stylesheet(theme) into a function of a QSettings value rather than of its arguments.

  • replace – allow re-registering name. Off by default so two widgets cannot quietly claim one name.

Raises:
spacr.qt.theme.registered_widget_qss(palette: dict, opacity: float | None = None, *, names=None) → str[source]

Render every registered block into one QSS fragment.

Empty (not even a newline) while nothing is registered, which is what keeps the shipped stylesheet byte-identical to the one that had no seam at all.

A block that raises, or returns something that is not a string, is dropped with a logged traceback rather than taking the stylesheet down: an unstyled widget is a cosmetic fault, and an exception here would leave the whole application unstyled — black text on a black window — because one contributed widget had a typo.

Parameters:

palette – the palette dict passed, with opacity, to every registered block function.

spacr.qt.theme.relative_luminance(color: str) → float[source]

WCAG relative luminance of a #rrggbb colour, in [0, 1].

Parameters:

color – a #rgb or #rrggbb colour string.

spacr.qt.theme.repolish(widget) → None[source]

Reapply Qt styling after a widget property changes.

Parameters:

widget – the widget to unpolish, polish and update.

spacr.qt.theme.rim_colour(theme: str = 'dark') → str[source]

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.

spacr.qt.theme.scheme_of(theme: str) → str[source]

"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.

Parameters:

theme – one of THEMES.

Returns:

"light" when the page outshines the ink, else "dark".

spacr.qt.theme.scrim_alpha(theme: str, role: str) → float[source]

Opacity of surface role in theme. 1.0 unless translucent.

Parameters:
  • theme – the theme name, as passed to palette_for().

  • role – the surface role; roles and themes not in SCRIM_ALPHA give 1.0.

spacr.qt.theme.scrim_failures(theme: str) → List[str][source]

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.

Parameters:

theme – the theme name, as passed to palette_for().

spacr.qt.theme.scrim_report(theme: str) → List[dict][source]

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 SCRIM_ALPHA — the numbers a reviewer would otherwise have to re-derive to check that a solved alpha is the right one.

Parameters:

theme – the theme name, as passed to palette_for().

spacr.qt.theme.scrim_under(theme: str) → str[source]

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 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 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 (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.

Parameters:

theme – the theme name.

spacr.qt.theme.selection_ink(theme: str = 'dark') → str[source]

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.

spacr.qt.theme.set_a_sheeted_widgets_own_rule(widget, rule: str) → None[source]

Replace widget’s own QSS without losing the sheet it carries.

FOR A WIDGET THAT IS A SHEET ROOT. Before per-screen sheeting, a module screen’s own stylesheet held only its own rules and the APPLICATION carried the theme, so setStyleSheet on the screen was a safe, local thing to do. Now the screen may be carrying all ~73 KB of the window sheet, and a plain setStyleSheet throws the theme away.

Measured before this existed: wiping a shown page’s sheet the way AppScreen._sync_page_palette does left a probe under it resolving to #000000 on the dark theme, and it stayed that way until the next theme change – the digest check repairs a wipe at the widget’s next polish, and a page already on show does not get one.

Falls back to a plain assignment when there is no window sheet to preserve, which is every caller that never opted into spaCR styling and every test that does not apply a theme.

A WIDGET STILL BEING BUILT IS NOT SHEETED HERE. It is marked as a sheet target and sheeted on its first Polish as a page or its first Show, which is before its first paint; see _the_sheet_can_wait_for_the_show() for why, and for the three repolishes of a whole module screen that saves.

Parameters:
  • widget – the widget whose own rules are being replaced.

  • rule – the QSS the widget owns, or "" to own none.

spacr.qt.theme.set_spaceout_drift_seconds(seconds: float) → None[source]

Set the spaceout animation clock, clamped to zero or greater.

Parameters:

seconds – the new clock value, in seconds; negative values become 0.

spacr.qt.theme.set_widget_qss_context(app, theme: str, font_scale: float, surface_opacity: float | None) → None[source]

Record the exact live preference inputs for late screen blocks.

Parameters:
  • app – the application object the context is stored on; None does nothing.

  • theme – the active theme name.

  • font_scale – the active font scale, stored as a float.

  • surface_opacity – the page-opacity preference, or None for the theme’s designed scrim.

spacr.qt.theme.size_close_mark(button, body_px: int | None = None) → None[source]

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 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.

Parameters:

button – the close-mark button to give a fixed size.

spacr.qt.theme.solve_scrim_alpha(theme: str, role: str, colour_role: str | None = None) → float[source]

The opacity role should be painted at in theme.

Two bounds, pulling opposite ways:

  • 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.

  • 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 scrim_report().

Parameters:
  • theme – name of the palette whose surface is being solved.

  • role – palette surface role whose opacity is being chosen.

  • colour_role – palette entry the surface is painted with, when it differs from role — tile is painted with surface.

spacr.qt.theme.spaceout_drift(at: float | None = None) → float[source]

Return the continuous spaceout hue rotation in degrees.

Parameters:

at – Elapsed animation time in seconds. None uses the current drift clock.

Returns:

Hue rotation in [0, 360), or zero when spaceout is disabled.

spacr.qt.theme.spaceout_drift_seconds() → float[source]

Return the elapsed spaceout animation time in seconds.

spacr.qt.theme.spaceout_drift_step(at: float | None = None) → float[source]

Return spaceout_drift() quantised to its solved palette grid.

spacr.qt.theme.spaceout_enabled() → bool[source]

Return whether spaceout rendering is enabled for this process.

spacr.qt.theme.spaceout_palette(palette: dict, drift: float = 0.0, theme: str | None = None) → dict[source]

Re-hue a theme palette while preserving accessible luminance.

Roles absent from SPACEOUT_HUES are returned unchanged. Named ink roles are constrained to contrast-safe luminance bands for theme.

Parameters:
  • palette – Mapping from theme roles to colour values.

  • drift – Hue rotation in degrees applied to all mapped roles.

  • theme – Theme used to resolve contrast-safe ink bands, or None for a direct hue shift.

Returns:

A new role-to-colour mapping.

spacr.qt.theme.splash_dim_alpha(ink: str, bg: str, *, target: float = 3.0, floor: int = 110) → int[source]

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.

Parameters:
  • ink – the text colour, a #rgb or #rrggbb colour string.

  • bg – the background colour it is composited over, a #rgb or #rrggbb colour string.

spacr.qt.theme.stage_hover(stage: str) → str[source]

Hover colour for stage; unknown stages read as stable.

Parameters:

stage – the app stage, a key of STAGE_HOVER.

spacr.qt.theme.stylesheet(theme: str = 'dark', font_scale: float = 1.0, background: str | None = None, surface_opacity: float | None = None, *, load_widget_registrars: bool = True) → str[source]

Return the QSS string that styles every custom widget in the app.

Blocks registered with register_widget_qss() are appended after everything below, so a widget’s own rules win a specificity tie against the general ones.

Parameters:
  • theme – one of THEMES; unknown values fall back to dark.

  • font_scale – multiplier applied to every font size in FONT_SIZE. 1.0 = 100 %.

  • background – path to a background image. Only the themes in IMAGE_THEMES use it; None (the default, and what a first run mid-generation gets) falls back to a flat gradient.

  • surface_opacity – optional user-requested alpha for all shared module surfaces. None uses the theme’s designed scrims.

  • 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.

spacr.qt.theme.system_colour_scheme(app=None) → str | None[source]

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.

Parameters:

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).

spacr.qt.theme.take_the_scroll_arrows_off(root) → int[source]

Disable overflow buttons for every tab bar below root.

This changes only the visibility of the scroll buttons. Qt’s keyboard and mouse-wheel tab navigation remain available.

Parameters:

root (PySide6.QtWidgets.QWidget) – Widget, tab widget, or tab bar to inspect recursively.

Returns:

int – Number of distinct tab bars found.

spacr.qt.theme.unregister_widget_qss(name: str) → bool[source]

Drop a registered block. True if there was one.

Parameters:

name – the name the block was registered under, converted to a string.

spacr.qt.theme.use_a_style_that_honours_the_palette(app=None, environ=None) → str[source]

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.)

Parameters:
  • app – the application; QApplication.instance() when omitted.

  • environ – environment to consult; os.environ when omitted.

Returns:

the name of the style in force afterwards, lower case.

spacr.qt.theme.widget_qss_names() → Tuple[str, ...][source]

Every registered block name, in registration order.

spacr.qt.theme.window_stylesheet(app=None) → str | None[source]

The sheet 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.

Parameters:

app – the application to read it off; the running one by default.

Returns:

the sheet, or None if no per-window sheet is installed.

Nested helpers

_hue_shift.tint(step: int) → Tuple[int, int, int]

The base hue at step, lightened toward white.

spacr/qt/theme.py:1134

_hue_shift.value(level: int) → Tuple[int, int, int]

The base hue at level, darkened toward black.

spacr/qt/theme.py:1128

_relative_luminance.channel(value: int) → float

One sRGB channel linearised, per WCAG’s own definition.

spacr/qt/theme.py:953

_scrim_bounds.over(alpha: float, beneath: Tuple[int, int, int]) → float

The luminance of the scrim at alpha over one background.

spacr/qt/theme.py:1652

_scrim_bounds.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.

spacr/qt/theme.py:1668