diff --git a/PyReconstruct/modules/datatypes/default_settings.py b/PyReconstruct/modules/datatypes/default_settings.py index d5b6115b..dd406e80 100644 --- a/PyReconstruct/modules/datatypes/default_settings.py +++ b/PyReconstruct/modules/datatypes/default_settings.py @@ -138,8 +138,9 @@ def get_username() -> str: # series-related "series_code_pattern": "[0-9A-Za-z]+", # MFO - # theme: "system" (follow OS light/dark), "light", or "dark". - # Legacy values "default"/"qdark" are migrated by gui.utils.theme.normalize_mode. + # theme: "system" (follow OS light/dark), "light", "dark", "studio" + # (cool dark + teal), or "atlas" (cool light + teal). Legacy values + # "default"/"qdark" are migrated by gui.utils.theme.normalize_mode. "theme": "system", # MFO # updates diff --git a/PyReconstruct/modules/gui/dialog/all_options.py b/PyReconstruct/modules/gui/dialog/all_options.py index 4fbcc211..ebe6efa9 100644 --- a/PyReconstruct/modules/gui/dialog/all_options.py +++ b/PyReconstruct/modules/gui/dialog/all_options.py @@ -257,18 +257,22 @@ def setOption(response): ("System", mode == "system"), ("Light", mode == "light"), ("Dark", mode == "dark"), + ("Studio", mode == "studio"), + ("Atlas", mode == "atlas"), )], ] def setOption(response): theme = self.series.getOption("theme") # keep current if neither radio matched - if response[0][0][1]: theme = "system" - elif response[0][1][1]: theme = "light" - elif response[0][2][1]: theme = "dark" + flags = response[0] + if flags[0][1]: theme = "system" + elif flags[1][1]: theme = "light" + elif flags[2][1]: theme = "dark" + elif len(flags) > 3 and flags[3][1]: theme = "studio" + elif len(flags) > 4 and flags[4][1]: theme = "atlas" self.series.setOption("theme", theme) - self.addOptionWidget("theme", structure, setOption) # scale bar opts diff --git a/PyReconstruct/modules/gui/main/main_window.py b/PyReconstruct/modules/gui/main/main_window.py index 1557e31e..067e73e6 100644 --- a/PyReconstruct/modules/gui/main/main_window.py +++ b/PyReconstruct/modules/gui/main/main_window.py @@ -3070,7 +3070,8 @@ def load3DScene(self): def setTheme(self, new_theme=None): """Set the UI theme. - Modes: "system" (follow the OS light/dark scheme), "light", "dark". + Modes: "system" (follow the OS light/dark scheme), "light", "dark", + "studio" (cool dark, teal accent), "atlas" (cool light, teal accent). Persisted app-wide via QSettings; legacy "default"/"qdark" are accepted and migrated. Called with None from the menu to prompt; called with a stored value at startup / after the options dialog to (re)apply.""" @@ -3081,7 +3082,9 @@ def setTheme(self, new_theme=None): [("radio", ("System", mode == "system"), ("Light", mode == "light"), - ("Dark", mode == "dark"))] + ("Dark", mode == "dark"), + ("Studio", mode == "studio"), + ("Atlas", mode == "atlas"))] ] response, confirmed = QuickDialog.get( self, structure, "Theme" @@ -3089,12 +3092,17 @@ def setTheme(self, new_theme=None): if not confirmed: return - if response[0][0][1]: + flags = response[0] + if flags[0][1]: new_theme = "system" - elif response[0][1][1]: + elif flags[1][1]: new_theme = "light" - elif response[0][2][1]: + elif flags[2][1]: new_theme = "dark" + elif flags[3][1]: + new_theme = "studio" + elif flags[4][1]: + new_theme = "atlas" else: return diff --git a/PyReconstruct/modules/gui/palette/mouse_palette.py b/PyReconstruct/modules/gui/palette/mouse_palette.py index 63f9cb4b..929fa25f 100644 --- a/PyReconstruct/modules/gui/palette/mouse_palette.py +++ b/PyReconstruct/modules/gui/palette/mouse_palette.py @@ -214,7 +214,7 @@ def refreshModeIcons(self): stripped = self._stripped(name) self._applyModeButtonStyle(b) if icon_utils.has_icon(stripped): - color = theme.ACCENT_TEXT if b.isChecked() else resting + color = theme.accent_text() if b.isChecked() else resting b.setIcon(icon_utils.tool_icon(stripped, icon_px, color)) b.setIconSize(QSize(icon_px, icon_px)) @@ -225,9 +225,10 @@ def _applyModeButtonStyle(button): own styling. Re-applied on theme/active changes so it follows light/dark. """ if button.isChecked(): + accent = theme.accent_color() button.setStyleSheet( "QPushButton { background-color: %s; border: 1px solid %s; " - "border-radius: 6px; }" % (theme.ACCENT, theme.ACCENT) + "border-radius: 6px; }" % (accent, accent) ) else: button.setStyleSheet("") diff --git a/PyReconstruct/modules/gui/utils/theme.py b/PyReconstruct/modules/gui/utils/theme.py index 77a3a7c7..09ba91dc 100644 --- a/PyReconstruct/modules/gui/utils/theme.py +++ b/PyReconstruct/modules/gui/utils/theme.py @@ -1,23 +1,40 @@ """Application theme engine. Centralizes PyReconstruct's UI chrome. The theme **defaults to the OS color -scheme** (light/dark) and can be overridden to System / Light / Dark; the choice -is persisted app-wide via QSettings. Dark keeps the established ``qdarkstyle`` -look (its ``DarkPalette``); light is ``qdarkstyle``'s clean ``LightPalette`` -counterpart. A small token palette (charcoal ground, calm azure accent) is -layered on top of both. +scheme** (light/dark) and can be overridden to one of several named themes; the +choice is persisted app-wide via QSettings. + +Themes (the values a user can pick and that get persisted): + + * ``system`` — follow the OS light/dark scheme (the default). + * ``light`` / ``dark`` — qdarkstyle's clean ``LightPalette`` / established + ``DarkPalette`` look, with a calm azure accent layered on selection chrome. + * ``studio`` — the "Studio" concept: a cool, dark, scientific base + (charcoal ``#0c0f14`` ground) with a calm **teal** accent (``#37c0a6``). + * ``atlas`` — the "Atlas" concept: Studio's light sibling — a cool, neutral + light shell for bright rooms / brightfield work, same teal accent. + +``studio`` and ``atlas`` are built on qdarkstyle's Dark / Light base (for full, +consistent widget coverage) and then *remapped*: every qdarkstyle palette hex in +the generated stylesheet is substituted for its Studio / Atlas counterpart in a +single pass. The qdarkstyle palette constants are read at run time, so the remap +tracks the installed qdarkstyle version rather than hard-coding its hues. Trace and segmentation colors are *data* (drawn in the field, not via QSS) and -are deliberately never touched here. +are deliberately never touched here. The Studio mockup's status hues +(curated / review / flagged) are likewise data, not chrome, so they are not part +of this engine. The persisted preference shares the global ``QSettings`` ``"theme"`` key with ``Series.getOption``/``setOption`` (that option already resolves to the global ``QSettings("KHLab", "PyReconstruct")`` store), so the two stay in sync. Qt/qdarkstyle are imported lazily inside the functions that need them so the -pure mode-resolution logic (:func:`normalize_mode`, :func:`resolve_scheme`) -stays importable and unit-testable without a Qt/qdarkstyle install. +pure resolution logic (:func:`normalize_mode`, :func:`resolve_theme`, +:func:`resolve_scheme`) stays importable and unit-testable without a +Qt/qdarkstyle install. """ +import re # --- persisted preference (shared with Series.getOption/setOption) ----------- QSETTINGS_ORG = "KHLab" @@ -25,11 +42,21 @@ THEME_KEY = "theme" #: the modes a user can choose; also the persisted values -MODES = ("system", "light", "dark") +MODES = ("system", "light", "dark", "studio", "atlas") -#: the two concrete schemes the chrome can resolve to +#: the concrete themes a stylesheet can be built for (``system`` resolves to one +#: of these). ``light``/``dark`` are the qdarkstyle base looks; ``studio`` and +#: ``atlas`` are the named concept palettes. +THEMES = ("light", "dark", "studio", "atlas") + +#: the two color *families*. Every concrete theme belongs to one; the family +#: drives anything that only cares about light-vs-dark (the app icon glyph, the +#: monochrome tool-icon tint, qdarkstyle's base palette). SCHEMES = ("light", "dark") +#: which family each concrete theme belongs to +_FAMILY = {"light": "light", "dark": "dark", "studio": "dark", "atlas": "light"} + #: When the OS scheme is unknowable (Qt < 6.8 on Linux without a desktop #: portal reports ``ColorScheme.Unknown``, and offscreen always does), fall #: back to dark. This matches the shipped app-icon logic (Unknown -> dark @@ -38,21 +65,100 @@ UNKNOWN_FALLBACK = "dark" # --- token palette ----------------------------------------------------------- -# Provisional hues: the engine and persistence are headless-verified, but the -# exact look (and the accent's specificity vs qdarkstyle's own selection color) -# is signed off interactively. Tune these constants, not the wiring. -ACCENT = "#3d8bd4" # calm azure — selection/highlight accent +# Azure accent for the qdarkstyle-based light/dark themes. (Provisional hues: +# the engine and persistence are headless-verified; the exact look is signed off +# interactively. Tune these constants, not the wiring.) +ACCENT = "#3d8bd4" # calm azure — selection/highlight accent (light/dark) ACCENT_TEXT = "#ffffff" # text drawn on the accent GROUND_DARK = "#19232d" # qdarkstyle DarkPalette ground (informational) GROUND_LIGHT = "#fafafa" # qdarkstyle LightPalette ground (informational) -# Tool-button icon color (the monochrome line icons tint to this per scheme). +# Tool-button icon color (the monochrome line icons tint to this per theme). # Provisional — from the v1 prototype's resting tool color (its --txt-dim). -ICON_DARK = "#9aa7bb" # icons on dark chrome -ICON_LIGHT = "#54627a" # icons on light chrome - - -# --- pure mode/scheme logic (no Qt) ------------------------------------------ +ICON_DARK = "#9aa7bb" # icons on dark (qdarkstyle) chrome +ICON_LIGHT = "#54627a" # icons on light (qdarkstyle) chrome + +# --- named-theme palettes (the "Studio" / "Atlas" concepts) ------------------ +# Studio's palette is lifted verbatim from the UI-direction mockup +# (artifact a386d6e8 — "Direction A: Studio"). Atlas is that mockup's described +# light sibling ("Direction B: Atlas — light precision, same teal accent"); the +# mockup names it but gives no hex, so its neutral cool-light shell is derived +# here to mirror Studio while keeping the identical teal/cyan accent. +STUDIO_ACCENT = "#37c0a6" # teal — Studio/Atlas selection/highlight accent +STUDIO_GROUND = "#0c0f14" # cool charcoal ground (informational) +ATLAS_GROUND = "#f4f6fa" # cool near-white ground (informational) + +#: Studio: remap qdarkstyle ``DarkPalette`` -> Studio hues. Keys are qdarkstyle +#: palette-constant *names* (resolved to their hexes at run time); values are the +#: Studio targets. Background ramp runs darkest (BACKGROUND_1, the ground) to +#: lightest (BACKGROUND_6, handles); the accent ramp lands the true teal on +#: ACCENT_3 (qdarkstyle's focus/hover accent). +_STUDIO_MAP = { + "COLOR_BACKGROUND_1": "#0c0f14", # ground + "COLOR_BACKGROUND_2": "#141921", # panel + "COLOR_BACKGROUND_3": "#1b212b", # panel-2 / low raise + "COLOR_BACKGROUND_4": "#2a323e", # hairline / border (dominant) + "COLOR_BACKGROUND_5": "#3a4452", # hover / handle + "COLOR_BACKGROUND_6": "#4a5666", # lightest handle / scrollbar + "COLOR_TEXT_1": "#e8edf3", # ink + "COLOR_TEXT_2": "#aab4c0", + "COLOR_TEXT_3": "#9aa7b5", # muted + "COLOR_TEXT_4": "#6a7686", # faint (mockup --faint) + "COLOR_DISABLED": "#4f5a6b", + "COLOR_ACCENT_1": "#16332d", # darkest teal (pressed/selected base) + "COLOR_ACCENT_2": "#2b8675", # mid teal + "COLOR_ACCENT_3": "#37c0a6", # the teal accent (focus/hover) + "COLOR_ACCENT_4": "#45cdb2", + "COLOR_ACCENT_5": "#5ad8c0", # brightest teal +} + +#: Atlas: remap qdarkstyle ``LightPalette`` -> Atlas hues. Background ramp runs +#: lightest (BACKGROUND_1, the ground) to darkest (BACKGROUND_6, borders); the +#: teal accent lands on ACCENT_4 (qdarkstyle light's focus accent). +_ATLAS_MAP = { + "COLOR_BACKGROUND_1": "#f4f6fa", # cool light ground / surface + "COLOR_BACKGROUND_2": "#e9edf3", # alt rows / headers + "COLOR_BACKGROUND_3": "#dde3ec", + "COLOR_BACKGROUND_4": "#ccd4df", # hairline / border (dominant) + "COLOR_BACKGROUND_5": "#bcc5d2", + "COLOR_BACKGROUND_6": "#aab4c2", + "COLOR_TEXT_1": "#1a2230", # cool near-black ink + "COLOR_TEXT_2": "#33405a", + "COLOR_TEXT_3": "#586478", # muted + "COLOR_TEXT_4": "#8893a6", # faint + "COLOR_DISABLED": "#aab3bf", + "COLOR_ACCENT_1": "#d9f1ea", # light teal tint (hover/selection wash) + "COLOR_ACCENT_2": "#88dccc", + "COLOR_ACCENT_3": "#4cccb5", + "COLOR_ACCENT_4": "#37c0a6", # the teal accent + "COLOR_ACCENT_5": "#2ba892", # deeper teal (pressed) +} + +_THEME_REMAP = {"studio": _STUDIO_MAP, "atlas": _ATLAS_MAP} + +#: accent each concrete theme paints onto selection chrome / the active tool +_THEME_ACCENT = { + "light": ACCENT, "dark": ACCENT, + "studio": STUDIO_ACCENT, "atlas": STUDIO_ACCENT, +} + +#: color drawn *on* the accent (selected-row text, highlighted menu items, the +#: active-tool glyph). White reads on the azure light/dark accent, but the +#: brighter Studio/Atlas teal needs a dark ink — white on ``#37c0a6`` is only +#: ~2.3:1 (below WCAG's 3:1 floor), whereas the charcoal ground clears 8:1. +_THEME_ON_ACCENT = { + "light": ACCENT_TEXT, "dark": ACCENT_TEXT, + "studio": STUDIO_GROUND, "atlas": STUDIO_GROUND, +} + +#: resting tool-icon tint per theme (active tool uses :func:`accent_text`) +_THEME_ICON = { + "light": ICON_LIGHT, "dark": ICON_DARK, + "studio": "#9aa7b5", "atlas": "#586478", +} + + +# --- pure mode/theme/scheme logic (no Qt) ------------------------------------ def normalize_mode(value) -> str: """Coerce any stored/legacy theme value to one of :data:`MODES`. @@ -69,8 +175,8 @@ def normalize_mode(value) -> str: return "system" -def resolve_scheme(mode, system_is_light) -> str: - """Resolve a mode to a concrete scheme (``"light"`` / ``"dark"``). +def resolve_theme(mode, system_is_light) -> str: + """Resolve a mode to a concrete theme (one of :data:`THEMES`). Pure and Qt-free so the full matrix is unit-testable. @@ -79,11 +185,9 @@ def resolve_scheme(mode, system_is_light) -> str: system_is_light: ``True`` if the OS scheme is light, ``False`` if dark, ``None`` if unknown. """ - if mode == "light": - return "light" - if mode == "dark": - return "dark" - # system + if mode in THEMES: # light, dark, studio, atlas + return mode + # system: follow the OS if system_is_light is True: return "light" if system_is_light is False: @@ -91,6 +195,15 @@ def resolve_scheme(mode, system_is_light) -> str: return UNKNOWN_FALLBACK +def resolve_scheme(mode, system_is_light) -> str: + """Resolve a mode to a concrete color *family* (``"light"`` / ``"dark"``). + + Studio resolves to the dark family, Atlas to light. Used by anything that + only distinguishes light vs dark (the app-icon glyph, the tool-icon tint). + """ + return _FAMILY[resolve_theme(mode, system_is_light)] + + # --- Qt-facing helpers (lazy imports) ---------------------------------------- def _app(app=None): if app is not None: @@ -129,78 +242,154 @@ def write_mode(mode) -> str: return mode -def current_scheme(app=None, mode=None) -> str: - """The concrete scheme currently in effect (respects the manual override).""" +def current_theme(app=None, mode=None) -> str: + """The concrete theme currently in effect (respects the manual override).""" if mode is None: mode = read_mode() - return resolve_scheme(normalize_mode(mode), system_is_light(app)) + return resolve_theme(normalize_mode(mode), system_is_light(app)) -def icon_color(scheme=None, app=None) -> str: - """Hex color the monochrome tool icons should tint to for a scheme. +def current_scheme(app=None, mode=None) -> str: + """The concrete color family (light/dark) currently in effect.""" + return _FAMILY[current_theme(app, mode)] + - Defaults to the scheme currently in effect. +def accent_color(theme=None, app=None) -> str: + """Accent hex the active theme paints onto selection chrome / active tool. + + Azure for the qdarkstyle light/dark themes, teal for Studio/Atlas. Defaults + to the theme currently in effect. + """ + if theme is None: + theme = current_theme(app) + return _THEME_ACCENT.get(theme, ACCENT) + + +def accent_text(theme=None, app=None) -> str: + """Hex for text/icons drawn on the accent. + + White on the azure (light/dark) accent; a dark ink on the brighter + Studio/Atlas teal so a selected row's text and the active-tool glyph stay + legible. Defaults to the theme currently in effect. + """ + if theme is None: + theme = current_theme(app) + return _THEME_ON_ACCENT.get(theme, ACCENT_TEXT) + + +def icon_color(theme=None, app=None) -> str: + """Hex the monochrome tool icons should tint to for a theme. + + Accepts a concrete theme or a family (``"light"``/``"dark"``); defaults to + the theme currently in effect. """ - if scheme is None: - scheme = current_scheme(app) - return ICON_DARK if scheme == "dark" else ICON_LIGHT + if theme is None: + theme = current_theme(app) + if theme in _THEME_ICON: + return _THEME_ICON[theme] + # tolerate a family string that isn't a concrete theme + return ICON_DARK if _FAMILY.get(theme, "dark") == "dark" else ICON_LIGHT # --- stylesheet construction ------------------------------------------------- -# Applies to both schemes. The first two rules are the long-standing qdarkstyle -# fix-ups (transparent QPushButton border; QComboBox dropdown padding), now -# shared by light too. The rest applies the azure accent to selection chrome. -def _addon_qss() -> str: +# The accent add-on applies to every theme. The first two rules are the +# long-standing qdarkstyle fix-ups (transparent QPushButton border; QComboBox +# dropdown padding); the rest paints the theme accent onto selection chrome. +def _accent_addon_qss(accent: str, on_accent: str) -> str: return f""" QPushButton {{ border: 1px solid transparent; }} QComboBox {{ padding-right: 40px; }} QListView, QTreeView, QTableView, QListWidget, QTreeWidget, QTableWidget {{ - selection-background-color: {ACCENT}; - selection-color: {ACCENT_TEXT}; + selection-background-color: {accent}; + selection-color: {on_accent}; }} -QMenu::item:selected {{ background-color: {ACCENT}; color: {ACCENT_TEXT}; }} +QMenu::item:selected {{ background-color: {accent}; color: {on_accent}; }} """ -def build_stylesheet(scheme: str) -> str: - """Build the full app stylesheet for a scheme (qdarkstyle base + tokens). +def _addon_qss() -> str: + """The azure add-on for the light/dark themes (unchanged historical look).""" + return _accent_addon_qss(ACCENT, ACCENT_TEXT) + + +def _remap_palette(qss: str, base_palette, name_to_target: dict) -> str: + """Substitute a qdarkstyle base stylesheet's palette hexes for new ones. + + ``name_to_target`` maps qdarkstyle palette-constant *names* (e.g. + ``COLOR_BACKGROUND_1``) to target hexes. The source hex is read from + ``base_palette`` at run time, so the remap follows the installed qdarkstyle + version. Replacement is a single case-insensitive pass over 6-digit hex + tokens (the generated QSS uses no ``rgba()``), so a target that happens to + equal another source is never re-substituted. The trailing boundary keeps a + longer token (a hypothetical 8-digit ``#rrggbbaa``) from being partially + matched and corrupted. + """ + mapping = {} + for cname, target in name_to_target.items(): + src = getattr(base_palette, cname, None) + if src: + mapping[src.lower()] = target + if not mapping: + return qss + return re.sub( + r"#[0-9a-fA-F]{6}(?![0-9a-fA-F])", + lambda m: mapping.get(m.group(0).lower(), m.group(0)), + qss, + ) + + +def build_stylesheet(theme: str) -> str: + """Build the full app stylesheet for a concrete theme. + + ``light``/``dark`` are qdarkstyle's base look + the azure add-on (unchanged). + ``studio``/``atlas`` take the matching qdarkstyle base (Dark / Light), remap + its palette to the Studio / Atlas hues, then add the teal accent. Accepts a + family string (``"light"``/``"dark"``) as a tolerant alias. Raises ``ImportError`` if qdarkstyle is unavailable; callers decide whether to fall back to native styling. """ import qdarkstyle from qdarkstyle import DarkPalette, LightPalette - palette = DarkPalette if scheme == "dark" else LightPalette - base = qdarkstyle.load_stylesheet(qt_api="pyside6", palette=palette) + + if theme not in THEMES: + theme = resolve_scheme(theme, None) # tolerate a family/legacy string + base_palette = DarkPalette if _FAMILY[theme] == "dark" else LightPalette + base = qdarkstyle.load_stylesheet(qt_api="pyside6", palette=base_palette) + + remap = _THEME_REMAP.get(theme) + if remap is not None: + base = _remap_palette(base, base_palette, remap) + return base + _accent_addon_qss(_THEME_ACCENT[theme], _THEME_ON_ACCENT[theme]) return base + _addon_qss() def apply_theme(app=None, mode=None) -> str: """Resolve ``mode`` and apply the matching stylesheet app-wide. - Returns the concrete scheme applied. If qdarkstyle can't be imported, falls + Returns the concrete theme applied. If qdarkstyle can't be imported, falls back to native styling (empty stylesheet + standard palette) rather than raising, so startup never breaks on a missing optional dependency. """ app = _app(app) - scheme = current_scheme(app, mode) + theme = current_theme(app, mode) try: - qss = build_stylesheet(scheme) + qss = build_stylesheet(theme) except Exception: qss = "" app.setStyleSheet(qss) if not qss: app.setPalette(app.style().standardPalette()) - return scheme + return theme def qss_active(app=None) -> bool: """True when a (qdarkstyle-based) app stylesheet is in effect. Used by widgets that need to compensate qdarkstyle's metrics (e.g. table - column widths), which now applies to both light and dark schemes. + column widths), which applies to every theme (all are qdarkstyle-based). """ try: return bool(_app(app).styleSheet()) diff --git a/tests/test_theme.py b/tests/test_theme.py index 76e3afa6..16c915e9 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -15,6 +15,7 @@ user's real theme preference. """ import os +import re import pytest from PyReconstruct.modules.gui.utils import theme as t @@ -106,3 +107,167 @@ def test_persist_roundtrip_and_legacy_migration(qapp): assert t.read_mode() == "dark" QSettings(t.QSETTINGS_ORG, t.QSETTINGS_APP).setValue(t.THEME_KEY, "default") assert t.read_mode() == "system" + + +# --- named themes ("Studio" / "Atlas"): pure logic (no Qt) ------------------- +@pytest.mark.parametrize("value", ["studio", "atlas"]) +def test_normalize_mode_named_themes(value): + # the named themes are first-class selectable modes (round-trip unchanged) + assert t.normalize_mode(value) == value + + +@pytest.mark.parametrize("mode,sys_light,expected", [ + ("studio", None, "studio"), # an explicit named theme ignores the OS + ("studio", True, "studio"), + ("atlas", None, "atlas"), + ("atlas", False, "atlas"), + ("light", True, "light"), + ("dark", False, "dark"), + ("system", True, "light"), + ("system", False, "dark"), + ("system", None, "dark"), # Unknown OS scheme -> UNKNOWN_FALLBACK +]) +def test_resolve_theme(mode, sys_light, expected): + assert t.resolve_theme(mode, sys_light) == expected + + +@pytest.mark.parametrize("mode,family", [ + ("studio", "dark"), # Studio is dark-family (drives the dark app-icon glyph) + ("atlas", "light"), # Atlas is light-family + ("light", "light"), + ("dark", "dark"), +]) +def test_named_theme_family(mode, family): + # resolve_scheme stays a light/dark answer even for the named themes, so the + # app-icon glyph and tool-icon tint keep working unchanged. + assert t.resolve_scheme(mode, None) == family + + +def test_modes_themes_schemes_consistent(): + assert set(t.THEMES) <= set(t.MODES) + assert set(t.MODES) - set(t.THEMES) == {"system"} # system is the only non-theme mode + assert set(t.SCHEMES) == {"light", "dark"} + assert {"studio", "atlas"} <= set(t.THEMES) + + +def test_accent_and_icon_per_theme(): + # named themes share the teal accent; light/dark keep the azure one + assert t.accent_color("studio") == t.accent_color("atlas") == t.STUDIO_ACCENT + assert t.accent_color("light") == t.accent_color("dark") == t.ACCENT + assert t.STUDIO_ACCENT != t.ACCENT + # tool-icon tints differ per theme and are never the accent itself + for theme_name in t.THEMES: + assert t.icon_color(theme_name) not in (t.ACCENT, t.STUDIO_ACCENT) + assert t.icon_color("studio") != t.icon_color("atlas") + + +def test_accent_text_per_theme(): + # white ink reads on the azure light/dark accent; the brighter Studio/Atlas + # teal needs a dark ink instead (white on #37c0a6 is ~2.3:1, a WCAG fail). + assert t.accent_text("light") == t.accent_text("dark") == t.ACCENT_TEXT == "#ffffff" + for named in ("studio", "atlas"): + assert t.accent_text(named) != t.ACCENT_TEXT, "teal needs a dark on-accent ink" + assert t.accent_text(named) == t.STUDIO_GROUND + + +def test_remap_leaves_longer_hex_intact(): + # The remap targets only whole 6-digit tokens: a longer (8-digit alpha) hex + # must be left untouched, not partially rewritten. + class _P: + COLOR_X = "#19232d" + src = "a{color:#19232d}b{color:#19232dff}" + out = t._remap_palette(src, _P, {"COLOR_X": "#0c0f14"}) + assert "a{color:#0c0f14}" in out # standalone 6-digit token was remapped + assert "#19232dff" in out # 8-digit token left intact (not corrupted) + + +# --- named themes: Qt-backed (skipped without PySide6 / qdarkstyle) ---------- +def _hexes(qss): + return {h.lower() for h in re.findall(r"#[0-9a-fA-F]{6}", qss)} + + +def test_studio_atlas_stylesheets_distinct_and_teal(qapp): + light = t.build_stylesheet("light") + dark = t.build_stylesheet("dark") + studio = t.build_stylesheet("studio") + atlas = t.build_stylesheet("atlas") + for ss in (studio, atlas): + assert ss, "named-theme stylesheet must be non-empty" + assert t.STUDIO_ACCENT in ss, "named themes paint the teal accent" + assert len({light, dark, studio, atlas}) == 4, "all four themes are distinct" + # named themes carry teal (not azure); light/dark carry azure (not teal) + assert t.ACCENT not in studio and t.ACCENT not in atlas + assert t.STUDIO_ACCENT not in light and t.STUDIO_ACCENT not in dark + + +def test_light_dark_not_remapped(qapp): + # The named-theme remap must leave the historical light/dark look untouched: + # *every* hue qdarkstyle emits survives (the base is not remapped at all), + # and only the azure add-on is appended. + import qdarkstyle + from qdarkstyle import DarkPalette, LightPalette + for palette, theme_name in ((DarkPalette, "dark"), (LightPalette, "light")): + base = qdarkstyle.load_stylesheet(qt_api="pyside6", palette=palette) + built = t.build_stylesheet(theme_name) + assert _hexes(base) <= _hexes(built), "a qdarkstyle hue was altered" + assert t.ACCENT in built # azure accent add-on present + assert t.STUDIO_ACCENT not in built # no teal leaked into light/dark + + +def test_studio_remaps_dark_ground(qapp): + from qdarkstyle import DarkPalette + studio = t.build_stylesheet("studio") + # qdarkstyle's dark ground is gone; Studio's charcoal ground is in + assert DarkPalette.COLOR_BACKGROUND_1.lower() not in studio.lower() + assert t.STUDIO_GROUND in studio + + +def test_atlas_remaps_light_ground(qapp): + from qdarkstyle import LightPalette + atlas = t.build_stylesheet("atlas") + assert LightPalette.COLOR_BACKGROUND_1.lower() not in atlas.lower() + assert t.ATLAS_GROUND in atlas + + +def test_remap_is_complete_no_qdarkstyle_hue_survives(qapp): + # Every hue in a remapped stylesheet is a declared target (plus the ink drawn + # on the accent) — proof the single-pass remap covered the whole base QSS and + # no original qdarkstyle palette color leaked through. + studio = t.build_stylesheet("studio") + allowed = {v.lower() for v in t._STUDIO_MAP.values()} | {t.accent_text("studio").lower()} + assert _hexes(studio) <= allowed + atlas = t.build_stylesheet("atlas") + allowed = {v.lower() for v in t._ATLAS_MAP.values()} | {t.accent_text("atlas").lower()} + assert _hexes(atlas) <= allowed + + +def test_studio_remap_applies_to_every_used_slot(qapp): + # Self-adjusting fidelity check: for each Studio remap whose qdarkstyle source + # slot is actually used in the base QSS, the Studio target must appear. (Some + # qdarkstyle slots — e.g. BACKGROUND_2 — are defined but unused, so their + # targets legitimately don't render; this asserts the ones that should, do.) + import qdarkstyle + from qdarkstyle import DarkPalette + base = qdarkstyle.load_stylesheet(qt_api="pyside6", palette=DarkPalette).lower() + studio = t.build_stylesheet("studio").lower() + for cname, target in t._STUDIO_MAP.items(): + if getattr(DarkPalette, cname).lower() in base: + assert target.lower() in studio, f"{cname} -> {target} was not applied" + # the Studio identity anchors (ground, hairline, ink, teal) all map to used + # slots, so they are guaranteed present + for hexv in ("#0c0f14", "#2a323e", "#e8edf3", "#37c0a6"): + assert hexv in studio, f"Studio anchor {hexv} missing" + + +def test_apply_named_theme_installs_and_returns_theme(qapp): + for theme_name in ("studio", "atlas"): + applied = t.apply_theme(qapp, theme_name) + assert applied == theme_name + assert qapp.styleSheet(), f"{theme_name!r} left an empty app stylesheet" + assert t.qss_active(qapp) is True + + +def test_persist_named_theme_roundtrip(qapp): + for mode in ("studio", "atlas"): + t.write_mode(mode) + assert t.read_mode() == mode