From aca55accb9be7517a28c39725d4d83ca052428c0 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Mon, 29 Jun 2026 06:48:22 -0500 Subject: [PATCH 1/6] feat(theme): add Studio and Atlas named themes with chrome tokens Add the Studio (dark) and Atlas (light) named themes to the theme engine. Both build on the qdarkstyle Dark/Light base, remap it to the concept palettes, and carry a single teal accent (#37c0a6) with dark ink on the accent (white on the brighter teal fails WCAG). Add studio_tokens() as the one source of chrome tokens for the bespoke Studio-layout widgets; the canvas and the controls floating over it stay dark in both shells. The light/dark themes keep their historical azure look unchanged. Expand the theme tests to cover named-theme resolution, the teal accent and on-accent ink, the chrome-token table, and that no azure appears anywhere in the Studio/Atlas chrome. --- PyReconstruct/modules/gui/utils/theme.py | 391 ++++++++++++++++++++--- tests/test_theme.py | 241 +++++++++++++- 2 files changed, 568 insertions(+), 64 deletions(-) diff --git a/PyReconstruct/modules/gui/utils/theme.py b/PyReconstruct/modules/gui/utils/theme.py index 77a3a7c7..c43ab043 100644 --- a/PyReconstruct/modules/gui/utils/theme.py +++ b/PyReconstruct/modules/gui/utils/theme.py @@ -1,23 +1,48 @@ """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. + +On top of that base, the bespoke **Studio layout** widgets (activity rail, +Objects panel, glassy canvas overlays, tool rail, palette strip, status bar) are +styled from an explicit chrome-token table, :func:`studio_tokens`. That table is +the single source of truth for those widgets' QSS and custom painting. Two rules +shape it: there is exactly one interactive accent (teal ``#37c0a6``), and the EM +canvas — and therefore the glassy controls floating over it — stays dark in both +Studio and Atlas (EM imagery belongs on dark). Trace and segmentation colors are *data* (drawn in the field, not via QSS) and -are deliberately never touched here. +are deliberately never touched by the chrome. The Studio mockup's curation hues +(curated / review / flagged) are surfaced as small status indicators by the +layout widgets, but are likewise fixed data hues, not theme chrome. 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 +50,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 +73,172 @@ 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", +} + +# --- Studio-layout chrome tokens --------------------------------------------- +# The bespoke layout widgets paint from these explicit tokens (not qdarkstyle). +# Two families: a Studio (dark) shell and an Atlas (light) shell. The canvas and +# the glassy controls floating over it are identical in both — the EM imagery is +# always on dark — so those live in ``_CANVAS_TOKENS`` and merge into each shell. +# +# Color formats are Qt-stylesheet-ready: plain ``#rrggbb`` hexes, ``rgba(...)`` +# where alpha is needed (Qt QSS does NOT accept ``#rrggbbaa``), and +# ``qlineargradient(...)`` strings for the lit-from-within active states. + +#: shared dark canvas + glassy floating controls (both Studio and Atlas) +_CANVAS_TOKENS = { + "canvas_bg": "#0c0f14", # EM ground (always dark) + "canvas_bg_2": "#0e1218", # subtle checker alt for the canvas + "float_bg": "rgba(17, 22, 29, 0.80)", # glassy card (#11161d @ .8 == cc) + "float_line": "#2a323e", + "float_ink": "#e8edf3", + "float_muted": "#9aa7b5", + "float_faint": "#6a7686", + "scale_bar": "#e8edf3", # the bright scale bar itself + "scale_ink": "#cdd6df", # its dimmer mono label (spec mockup) + "raised_dark": "#222a35", # secnav buttons / B&C tracks + # curation status hues (DATA, fixed in both themes) + "ok": "#3ecf8e", "warn": "#f4bd4f", "bad": "#f07a72", + # the one accent + the rare secondary, and the dark ink drawn on solid teal + "accent": "#37c0a6", "accent_2": "#5ab0f0", "on_accent": "#0c0f14", +} + +#: Studio (dark) shell +_STUDIO_TOKENS = { + "family": "dark", + "bg": "#0c0f14", "panel": "#141921", "panel_2": "#1b212b", "raised": "#222a35", + "line": "#2a323e", "line_soft": "#212833", + "ink": "#e8edf3", "muted": "#9aa7b5", "faint": "#6a7686", + # activity-rail active item: lit-from-within wash + inset teal ring + "rail_active_bg": + "qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #22404a, stop:1 #1a2a33)", + "rail_active_ring": "#2c5a5a", + # tool-rail active tile: lit gradient + teal border, NO glow + "tool_active_bg": + "qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #243a44, stop:1 #1a2730)", + "tool_active_border": "#2c5a5a", + "tool_icon_rest": "#9aa7b5", "tool_icon_active": "#37c0a6", + # selected object row: teal left bar + a wash that fades to nothing + "row_sel_a": "#1a2c33", "row_sel_b": "rgba(26, 44, 51, 0)", "row_sel_bar": "#37c0a6", + # alignment chip (titlebar) + "chip_bg": "#15324a", "chip_ink": "#9ed2ff", "chip_border": "#1e466b", +} +_STUDIO_TOKENS.update(_CANVAS_TOKENS) + +#: Atlas (light) shell — same layout + same teal, light neutrals; dark canvas +_ATLAS_TOKENS = { + "family": "light", + "bg": "#f4f6fa", "panel": "#e9edf3", "panel_2": "#dde3ec", "raised": "#d2dae5", + "line": "#ccd4df", "line_soft": "#dde3ec", + "ink": "#1a2230", "muted": "#586478", "faint": "#8893a6", + "rail_active_bg": + "qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #dff3ee, stop:1 #cdebe1)", + "rail_active_ring": "#7fcbb9", + "tool_active_bg": + "qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #dff3ee, stop:1 #cdebe1)", + "tool_active_border": "#7fcbb9", + "tool_icon_rest": "#586478", "tool_icon_active": "#37c0a6", + "row_sel_a": "#d9f1ea", "row_sel_b": "rgba(217, 241, 234, 0)", "row_sel_bar": "#37c0a6", + "chip_bg": "#dbeefe", "chip_ink": "#1d5a86", "chip_border": "#b6dcf5", +} +_ATLAS_TOKENS.update(_CANVAS_TOKENS) + +#: chrome tokens by family (the Studio layout is always teal-accented; the +#: theme toggle only swaps the light-vs-dark *shell* around the dark canvas) +_LAYOUT_TOKENS = {"dark": _STUDIO_TOKENS, "light": _ATLAS_TOKENS} + + +# --- 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 +255,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 +265,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 +275,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 +322,172 @@ 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 scheme is None: - scheme = current_scheme(app) - return ICON_DARK if scheme == "dark" else ICON_LIGHT + 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 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 + + +def studio_tokens(theme=None, app=None) -> dict: + """Chrome-token table for the bespoke Studio layout widgets. + + Returns a flat dict of Qt-stylesheet-ready color tokens (see the + ``_*_TOKENS`` tables above) for the theme currently in effect, or for an + explicitly named theme/family. The Studio layout is always teal-accented; + the toggle only swaps the light-vs-dark *shell* around an always-dark + canvas, so the returned table is selected purely by color family — Studio + and ``dark`` share the dark shell, Atlas and ``light`` the light shell. + + The returned dict is a fresh copy; callers may mutate it freely. + """ + if theme is None: + theme = current_theme(app) + family = _FAMILY.get(theme, theme if theme in SCHEMES else "dark") + return dict(_LAYOUT_TOKENS[family]) # --- 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..e2f7bf70 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -1,25 +1,38 @@ """Tests for the application theme engine (gui.utils.theme). The theme defaults to the OS color scheme and can be overridden to -System / Light / Dark, persisted app-wide via QSettings. These pin: +System / Light / Dark / Studio / Atlas, persisted app-wide via QSettings. +These pin: - * the pure mode/scheme resolution (no Qt) over its full matrix, including the - legacy "default"/"qdark" migration and the Unknown->dark fallback that keeps - chrome and the app icon in agreement; - * the Qt-backed pieces (skipped if PySide6/qdarkstyle are unavailable): that - light and dark build distinct non-empty stylesheets carrying the accent - token, that apply_theme installs one app-wide, and that the persisted choice - round-trips through QSettings. + * the pure mode/theme/scheme resolution (no Qt) over its full matrix, + including the legacy "default"/"qdark" migration, the Unknown->dark + fallback that keeps chrome and the app icon in agreement, and the named + Studio/Atlas themes (their family, accent, and on-accent ink); + * the qdarkstyle-base stylesheets (skipped if PySide6/qdarkstyle are + unavailable): that the four concrete themes build distinct non-empty + stylesheets, that the named themes carry the teal accent (and never the + azure one, nor any leftover qdarkstyle hue), that light/dark are left + untouched, that apply_theme installs one app-wide, and that the persisted + choice round-trips through QSettings; + * the Studio-layout chrome-token table (studio_tokens): its family selection, + the single teal accent, the always-dark canvas in both shells, and the hard + rule that no azure leaks into the Studio chrome. The Qt fixture redirects QSettings to a temp dir so a test run never touches the user's real theme preference. """ import os +import re import pytest from PyReconstruct.modules.gui.utils import theme as t +# the azure hues that must never appear in the Studio/Atlas chrome (the recurring +# first-attempt failure was azure #4c8dff leaking in from Refined Familiar) +AZURE_HUES = ("#4c8dff", "#3d8bd4") + + # --- pure logic (no Qt) ------------------------------------------------------ @pytest.mark.parametrize("value,expected", [ ("system", "system"), @@ -55,6 +68,124 @@ def test_unknown_fallback_matches_icon_logic(): assert t.UNKNOWN_FALLBACK == "dark" +# --- 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) + + +# --- Studio-layout chrome tokens (studio_tokens): pure logic (no Qt) --------- +def test_studio_tokens_family_selection(): + # Studio and bare "dark" share the dark shell; Atlas and "light" the light. + assert t.studio_tokens("studio") == t.studio_tokens("dark") + assert t.studio_tokens("atlas") == t.studio_tokens("light") + assert t.studio_tokens("studio")["family"] == "dark" + assert t.studio_tokens("atlas")["family"] == "light" + # the shells differ on the neutral ground but never on the accent + assert t.studio_tokens("studio")["bg"] == "#0c0f14" + assert t.studio_tokens("atlas")["bg"] == "#f4f6fa" + + +def test_studio_tokens_one_teal_accent_dark_ink(): + for shell in ("studio", "atlas"): + tok = t.studio_tokens(shell) + assert tok["accent"] == t.STUDIO_ACCENT == "#37c0a6" + assert tok["on_accent"] == "#0c0f14", "dark ink on solid teal fills" + # curation status hues are the fixed data palette, identical in both + assert (tok["ok"], tok["warn"], tok["bad"]) == ("#3ecf8e", "#f4bd4f", "#f07a72") + + +def test_studio_tokens_canvas_dark_in_both_shells(): + # The EM canvas — and the glassy controls floating on it — stay dark in both + # Studio and Atlas, so every canvas/float token is shell-independent. + dark, light = t.studio_tokens("studio"), t.studio_tokens("atlas") + for key in ("canvas_bg", "float_bg", "float_line", "float_ink", "scale_ink", + "raised_dark", "accent", "on_accent", "ok", "warn", "bad"): + assert dark[key] == light[key], f"{key} must match across shells" + assert dark["canvas_bg"] == "#0c0f14" + assert dark["float_bg"].startswith("rgba("), "alpha float bg needs Qt rgba(), not #rrggbbaa" + + +def test_studio_tokens_no_azure(): + # The hard rule: no azure anywhere in the Studio/Atlas chrome tokens. + for shell in ("studio", "atlas"): + blob = " ".join(str(v) for v in t.studio_tokens(shell).values()).lower() + for azure in AZURE_HUES: + assert azure not in blob, f"azure {azure} leaked into {shell} chrome tokens" + + +def test_studio_tokens_returns_fresh_copy(): + a = t.studio_tokens("studio") + a["bg"] = "#ffffff" + assert t.studio_tokens("studio")["bg"] == "#0c0f14", "caller mutation must not stick" + + # --- Qt-backed (skipped without PySide6 / qdarkstyle) ------------------------ @pytest.fixture(scope="module") def qapp(tmp_path_factory): @@ -81,9 +212,9 @@ def test_build_stylesheet_light_dark_differ(qapp): def test_apply_theme_installs_stylesheet(qapp): - for mode in ("light", "dark", "system"): - scheme = t.apply_theme(qapp, mode) - assert scheme in t.SCHEMES + for mode in ("light", "dark", "studio", "atlas", "system"): + theme_applied = t.apply_theme(qapp, mode) + assert theme_applied in t.THEMES assert qapp.styleSheet(), f"mode {mode!r} left an empty app stylesheet" assert t.qss_active(qapp) is True @@ -98,7 +229,7 @@ def test_toggle_changes_active_stylesheet(qapp): def test_persist_roundtrip_and_legacy_migration(qapp): from PySide6.QtCore import QSettings - for mode in ("system", "light", "dark"): + for mode in ("system", "light", "dark", "studio", "atlas"): t.write_mode(mode) assert t.read_mode() == mode # legacy stored values are migrated on read-back @@ -106,3 +237,89 @@ 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: 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_studio_atlas_stylesheets_have_no_azure(qapp): + # zero azure anywhere in the named-theme stylesheets + for theme_name in ("studio", "atlas"): + ss = t.build_stylesheet(theme_name).lower() + for azure in AZURE_HUES: + assert azure not in ss, f"azure {azure} leaked into the {theme_name} stylesheet" + + +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" From c521bd41988e62d27592a36e71401772da716722 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Mon, 29 Jun 2026 06:48:36 -0500 Subject: [PATCH 2/6] feat(icons): add Studio activity- and tool-rail vector icons Add stroked 24x24 SVGs for the activity rail (objects, traces, sections, 3D scene, settings), the tool rail's Flag and Host tiles, the section-nav chevrons, and the Objects-panel search field. They render through the existing tinted-icon path, so the Studio rails use vector line icons rather than the mockup's placeholder glyphs. Flag and Host now resolve to icons; update the icon tests to match and to assert the Studio icon set is present. --- PyReconstruct/modules/gui/utils/icon_svgs.py | 70 ++++++++++++++++++++ tests/test_icons.py | 21 ++++-- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/PyReconstruct/modules/gui/utils/icon_svgs.py b/PyReconstruct/modules/gui/utils/icon_svgs.py index 2c12ad76..8b03120a 100644 --- a/PyReconstruct/modules/gui/utils/icon_svgs.py +++ b/PyReconstruct/modules/gui/utils/icon_svgs.py @@ -99,4 +99,74 @@ def _svg(body: str) -> bytes: '' ), + + # --- Studio layout chrome icons ----------------------------------------- + # Same 24x24 stroked grid + solid-black artwork as the tools above, so they + # tint through the identical render_svg_tinted path. The activity rail and + # the tool rail's Flag/Host tiles reference these (replacing the mockup's + # placeholder unicode glyphs ▣ ⤳ ▤ ⚑ ◈ ⚙ ⊹). + + # activity rail · Objects — a solid segmented-object silhouette + "objects": _svg( + '' + ), + # activity rail · Traces — a freehand path with two vertices + "traces": _svg( + '' + '' + '' + ), + # activity rail · Sections — an isometric z-stack of section planes + "sections": _svg( + '' + '' + '' + ), + # activity rail · 3D scene — a cube + "scene3d": _svg( + '' + '' + ), + # activity rail · Settings — a gear + "settings": _svg( + '' + '' + ), + # tool rail / activity rail · Flag — flag on a pole + "flag": _svg( + '' + '' + ), + # tool rail · Host — a host→hosted relationship (two linked nodes) + "host": _svg( + '' + '' + '' + ), + # Objects-panel filter field · Search — a magnifier + "search": _svg( + '' + '' + ), + # section-nav pill chevrons + "chevron_left": _svg( + '' + ), + "chevron_right": _svg( + '' + ), } diff --git a/tests/test_icons.py b/tests/test_icons.py index 2dea63bf..d91bddc0 100644 --- a/tests/test_icons.py +++ b/tests/test_icons.py @@ -16,7 +16,8 @@ # tools whose palette buttons load a PNG today and should get a modern SVG -# (Flag/Host stay as glyphs and are intentionally absent) +# (Flag/Host historically fell back to glyphs; they now have Studio-set icons +# too — see STUDIO_ICONS below) EXPECTED_TOOLS = { "pointer", "panzoom", "knife", "scissors", "closedtrace", "opentrace", "stamp", "grid", "ztool", @@ -28,6 +29,16 @@ def test_expected_tools_present(): "every standard mode tool must have a modern SVG" +# Studio-layout chrome icons (activity rail panels + the tool rail's Flag/Host +# tiles); these replace the mockup's placeholder unicode glyphs. +STUDIO_ICONS = {"objects", "traces", "sections", "scene3d", "settings", "flag", "host"} + + +def test_studio_chrome_icons_present(): + assert STUDIO_ICONS <= set(icon_svgs.TOOL_SVGS), \ + "the Studio activity/tool rails must have stroked vector icons (no unicode glyphs)" + + def test_svgs_are_wellformed_bytes(): for name, svg in icon_svgs.TOOL_SVGS.items(): assert isinstance(svg, bytes), f"{name} svg must be bytes" @@ -56,7 +67,7 @@ def qapp(): def test_render_tints_opaque_pixels(qapp): from PyReconstruct.modules.gui.utils import icons from PySide6.QtGui import QColor - color = "#4c8dff" + color = "#e84d8a" # any vivid non-grayscale tint exercises the exactness check target = QColor(color) # use a solid-filled icon (pointer) so there are plenty of fully-opaque # pixels to assert exactness on; thin line-art icons are mostly anti-aliased @@ -77,12 +88,14 @@ def test_render_tints_opaque_pixels(qapp): def test_tool_icon_known_and_unknown(qapp): from PyReconstruct.modules.gui.utils import icons - assert icons.has_icon("knife") and not icons.has_icon("flag") + # known tools (incl. the now-present Flag/Host) resolve; an unknown name does not + assert icons.has_icon("knife") and icons.has_icon("flag") and icons.has_icon("host") + assert not icons.has_icon("no_such_tool") icon = icons.tool_icon("knife", 32, "#ffffff") assert not icon.isNull() assert not icon.pixmap(32, 32).isNull() # unknown tool -> empty icon (caller falls back to PNG/glyph) - assert icons.tool_icon("flag", 32).isNull() + assert icons.tool_icon("no_such_tool", 32).isNull() def test_tool_icon_defaults_to_theme_color(qapp): From 30f05689ba35b349594538d80ce444a58d5b51ee Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Mon, 29 Jun 2026 06:48:36 -0500 Subject: [PATCH 3/6] =?UTF-8?q?feat(ui):=20build=20the=20Studio=20layout?= =?UTF-8?q?=20=E2=80=94=20activity=20rail,=20panel,=20canvas,=20rails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the gui/studio package implementing the Studio layout grammar: the activity rail; the first-class filterable Objects panel (model/view + a delegate that draws swatch, name, faint mono type, and a curation status dot, with a teal-bar selection and a curation legend); the dark canvas with glassy floating controls (active-object badge, scale bar, section-nav pill, and a brightness/contrast card with teal thumbs); the flush tool rail; the trace-palette strip; the mono status bar; and a title strip, all assembled by StudioShell. The widgets are a pure view layer (PySide6 plus the theme/icon utilities only), driven by data setters and signals, so they construct headlessly and can be adopted by the main window. Chrome comes from studio_tokens through a single QSS template; a theme toggle swaps the Studio (dark) and Atlas (light) shell around the always-dark canvas. Add dev/studio_preview.py to render both shells offscreen, and headless tests covering the structure and the non-negotiables (row anatomy, flush tool rail, zero azure, theme toggle, and honest 3D/Settings seams). --- PyReconstruct/modules/gui/studio/__init__.py | 21 + PyReconstruct/modules/gui/studio/_common.py | 64 +++ PyReconstruct/modules/gui/studio/canvas.py | 407 ++++++++++++++++++ .../modules/gui/studio/objects_panel.py | 295 +++++++++++++ .../modules/gui/studio/palette_strip.py | 130 ++++++ PyReconstruct/modules/gui/studio/qss.py | 211 +++++++++ PyReconstruct/modules/gui/studio/rail.py | 75 ++++ PyReconstruct/modules/gui/studio/shell.py | 241 +++++++++++ .../modules/gui/studio/status_bar.py | 81 ++++ .../modules/gui/studio/title_strip.py | 100 +++++ PyReconstruct/modules/gui/studio/tool_rail.py | 81 ++++ dev/studio_preview.py | 73 ++++ tests/test_studio_layout.py | 270 ++++++++++++ 13 files changed, 2049 insertions(+) create mode 100644 PyReconstruct/modules/gui/studio/__init__.py create mode 100644 PyReconstruct/modules/gui/studio/_common.py create mode 100644 PyReconstruct/modules/gui/studio/canvas.py create mode 100644 PyReconstruct/modules/gui/studio/objects_panel.py create mode 100644 PyReconstruct/modules/gui/studio/palette_strip.py create mode 100644 PyReconstruct/modules/gui/studio/qss.py create mode 100644 PyReconstruct/modules/gui/studio/rail.py create mode 100644 PyReconstruct/modules/gui/studio/shell.py create mode 100644 PyReconstruct/modules/gui/studio/status_bar.py create mode 100644 PyReconstruct/modules/gui/studio/title_strip.py create mode 100644 PyReconstruct/modules/gui/studio/tool_rail.py create mode 100644 dev/studio_preview.py create mode 100644 tests/test_studio_layout.py diff --git a/PyReconstruct/modules/gui/studio/__init__.py b/PyReconstruct/modules/gui/studio/__init__.py new file mode 100644 index 00000000..51e95edb --- /dev/null +++ b/PyReconstruct/modules/gui/studio/__init__.py @@ -0,0 +1,21 @@ +"""The "Studio" layout — the v1.30 UI overhaul's foundation. + +A self-contained set of bespoke Qt widgets implementing the Studio / Atlas +layout grammar from the canonical spec (activity rail · first-class Objects +panel · dark canvas with glassy floating controls · flush tool rail · +trace-palette strip · mono status bar), composed by :class:`~.shell.StudioShell`. + +These are a pure **view layer**: they import only PySide6 and the theme/icon +utilities, take their content through plain-data setters, and announce user +intent through signals. They hold no reference to the field, series, or any +heavy runtime, so they construct headlessly, render offscreen for sign-off, and +can be adopted incrementally by the main window (which feeds them real data). + +The one interactive accent is teal (``#37c0a6``); the EM canvas — and the +controls floating over it — stay dark in both Studio and Atlas. All chrome +color comes from :func:`PyReconstruct.modules.gui.utils.theme.studio_tokens`. +""" + +from .shell import StudioShell + +__all__ = ["StudioShell"] diff --git a/PyReconstruct/modules/gui/studio/_common.py b/PyReconstruct/modules/gui/studio/_common.py new file mode 100644 index 00000000..57ccf4a1 --- /dev/null +++ b/PyReconstruct/modules/gui/studio/_common.py @@ -0,0 +1,64 @@ +"""Small shared helpers for the Studio widgets.""" +from PySide6.QtCore import QSize +from PySide6.QtWidgets import QToolButton + +from ..utils import icons + + +def repolish(widget): + """Re-evaluate a widget's QSS after a dynamic property changed. + + Qt only re-runs property-dependent selectors (e.g. ``[active="true"]``) when + a widget is unpolished and re-polished; setting the property alone is not + enough. + """ + style = widget.style() + style.unpolish(widget) + style.polish(widget) + widget.update() + + +class IconTileButton(QToolButton): + """A square icon button that tints its stroked-vector icon to the theme. + + Used for both rails: it carries the resting and active icon tints, flips an + ``active`` QSS property (so the stylesheet paints the lit-from-within state), + and re-renders the icon to the right tint on every state or theme change. + The artwork is the shared :mod:`gui.utils.icon_svgs` set, rendered through + :func:`gui.utils.icons.tool_icon` (never a unicode glyph). + """ + + def __init__(self, key, icon_name, object_name, tile_px, icon_px, parent=None): + super().__init__(parent) + self.key = key + self.icon_name = icon_name + self._icon_px = icon_px + self._rest = "#9aa7b5" + self._active = "#37c0a6" + self.setObjectName(object_name) + self.setFixedSize(tile_px, tile_px) + self.setIconSize(QSize(icon_px, icon_px)) + self.setCheckable(False) + self.setFocusPolicy(self.focusPolicy().NoFocus) + self.setCursor(self.cursor().shape()) + self.setProperty("active", False) + self._render_icon() + + def retint(self, rest_color, active_color): + """Set the resting / active icon tints (typically on a theme change).""" + self._rest = rest_color + self._active = active_color + self._render_icon() + + def set_active(self, on: bool): + """Mark this tile active: flip the QSS property and re-tint the icon.""" + self.setProperty("active", bool(on)) + repolish(self) + self._render_icon() + + def is_active(self) -> bool: + return bool(self.property("active")) + + def _render_icon(self): + color = self._active if self.is_active() else self._rest + self.setIcon(icons.tool_icon(self.icon_name, self._icon_px, color)) diff --git a/PyReconstruct/modules/gui/studio/canvas.py b/PyReconstruct/modules/gui/studio/canvas.py new file mode 100644 index 00000000..e73d1e41 --- /dev/null +++ b/PyReconstruct/modules/gui/studio/canvas.py @@ -0,0 +1,407 @@ +"""The canvas — the dark work surface and its glassy floating controls. + +The EM canvas is always dark (in Studio *and* Atlas). Controls do not crowd it: +they float as glassy cards over the imagery so the work owns the screen — + + * active-object "needs review" badge (top-left), + * scale bar (bottom-left), + * section-nav pill ``‹ section 148 / 287 ›`` (bottom-center), + * brightness / contrast card with teal-thumb tracks (bottom-right). + +Brightness/contrast live here, on the canvas — not in a docked slider. The +floats are children repositioned on resize. :meth:`set_canvas_widget` is the +seam where the real field widget mounts later; until then the canvas paints a +representative dark EM/segmentation backdrop so the layout reads honestly. +""" +from PySide6.QtCore import Qt, Signal, QRectF, QPointF +from PySide6.QtGui import ( + QPainter, QColor, QBrush, QPen, QRadialGradient, QLinearGradient, +) +from PySide6.QtWidgets import ( + QWidget, QFrame, QHBoxLayout, QVBoxLayout, QLabel, QToolButton, QSizePolicy, +) + +from ..utils import theme, icons + +#: curation status -> (chrome token key, badge phrasing) +_STATUS = { + "curated": ("ok", "curated"), + "review": ("warn", "needs review"), + "flagged": ("bad", "flagged"), +} + + +class _Dot(QLabel): + """A small status dot (a data hue, constant across themes).""" + + def __init__(self, d=8, parent=None): + super().__init__(parent) + self._d = d + self.setFixedSize(d, d) + + def set_color(self, color_hex): + self.setStyleSheet(f"background:{color_hex}; border-radius:{self._d // 2}px;") + + +class TealTrack(QWidget): + """A minimal slider: a thin raised track with a teal thumb (0–100).""" + + valueChanged = Signal(int) + + def __init__(self, value=50, parent=None): + super().__init__(parent) + self._value = max(0, min(100, value)) + self._track = "#222a35" + self._accent = "#37c0a6" + self.setMinimumWidth(64) + self.setFixedHeight(14) + self.setCursor(Qt.PointingHandCursor) + + def set_tokens(self, tokens): + self._track = tokens["raised_dark"] + self._accent = tokens["accent"] + self.update() + + def value(self): + return self._value + + def setValue(self, v): + v = max(0, min(100, int(v))) + if v != self._value: + self._value = v + self.update() + self.valueChanged.emit(v) + + def _value_from_x(self, x): + usable = max(1, self.width() - 10) + return round((x - 5) / usable * 100) + + def mousePressEvent(self, event): + self.setValue(self._value_from_x(event.position().x())) + + def mouseMoveEvent(self, event): + if event.buttons() & Qt.LeftButton: + self.setValue(self._value_from_x(event.position().x())) + + def paintEvent(self, _event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + w, h = self.width(), self.height() + cy = h / 2 + p.setPen(Qt.NoPen) + p.setBrush(QColor(self._track)) + p.drawRoundedRect(QRectF(0, cy - 2, w, 4), 2, 2) + cx = 5 + (w - 10) * self._value / 100 + p.setBrush(QColor(self._accent)) + p.drawEllipse(QPointF(cx, cy), 5, 5) + p.end() + + +def _float_frame(name="studioFloat"): + f = QFrame() + f.setObjectName(name) + f.setAttribute(Qt.WA_StyledBackground, True) + return f + + +class ActiveObjectBadge(QFrame): + """Top-left glassy badge: a status dot + ``name · ``.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioFloat") + self.setAttribute(Qt.WA_StyledBackground, True) + lay = QHBoxLayout(self) + lay.setContentsMargins(9, 5, 10, 5) + lay.setSpacing(7) + self._dot = _Dot(8, self) + self._text = QLabel("", self) + self._text.setObjectName("studioFloatText") + lay.addWidget(self._dot) + lay.addWidget(self._text) + self._tok = theme.studio_tokens() + + def set_tokens(self, tokens): + self._tok = tokens + self._refresh() + + def set_object(self, name, status): + self._name = name + self._status = status + self._refresh() + + def _refresh(self): + name = getattr(self, "_name", "") + status = getattr(self, "_status", None) + tok_key, phrase = _STATUS.get(status, ("warn", "needs review")) + self._dot.set_color(self._tok[tok_key]) + self._text.setText(f"{name} · {phrase}" if name else phrase) + self.adjustSize() + + +class ScaleBar(QWidget): + """Bottom-left scale bar: a solid bar + a mono measurement (no card).""" + + def __init__(self, parent=None): + super().__init__(parent) + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(4) + self._bar = QFrame(self) + self._bar.setFixedSize(80, 3) + self._text = QLabel("2 µm", self) + self._text.setObjectName("studioScaleText") + lay.addWidget(self._bar) + lay.addWidget(self._text) + self._tok = theme.studio_tokens() + self._apply() + + def set_tokens(self, tokens): + self._tok = tokens + self._apply() + + def set_text(self, text): + self._text.setText(text) + self.adjustSize() + + def _apply(self): + self._bar.setStyleSheet(f"background:{self._tok['scale_bar']}; border-radius:1px;") + + +class SectionNavPill(QFrame): + """Bottom-center glassy pill: ``‹ section / ›``.""" + + stepped = Signal(int) # -1 previous, +1 next + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioFloat") + self.setAttribute(Qt.WA_StyledBackground, True) + lay = QHBoxLayout(self) + lay.setContentsMargins(10, 6, 10, 6) + lay.setSpacing(8) + self._prev = self._chev("chevron_left", -1) + self._label = QLabel("", self) + self._label.setObjectName("studioSecnavLabel") + self._label.setTextFormat(Qt.RichText) + self._next = self._chev("chevron_right", +1) + lay.addWidget(self._prev) + lay.addWidget(self._label) + lay.addWidget(self._next) + self._tok = theme.studio_tokens() + self._idx, self._total = 1, 1 + self._refresh_icons() + self.set_section(148, 287) + + def _chev(self, icon_name, delta): + b = QToolButton(self) + b.setObjectName("studioSecnavButton") + b.setFixedSize(24, 24) + b.setFocusPolicy(Qt.NoFocus) + b.setCursor(Qt.PointingHandCursor) + b._icon_name = icon_name + b.clicked.connect(lambda _=False, d=delta: self.stepped.emit(d)) + return b + + def set_tokens(self, tokens): + self._tok = tokens + self._refresh_icons() + self._refresh_label() + + def set_section(self, idx, total): + self._idx, self._total = idx, total + self._refresh_label() + + def _refresh_icons(self): + for b in (self._prev, self._next): + b.setIcon(icons.tool_icon(b._icon_name, 14, self._tok["float_muted"])) + + def _refresh_label(self): + faint = self._tok["float_faint"] + self._label.setText( + f"section {self._idx}" + f" / {self._total}" + ) + self.adjustSize() + + +class BrightnessContrastCard(QFrame): + """Bottom-right glassy card: brightness + contrast on teal-thumb tracks.""" + + brightness_changed = Signal(int) + contrast_changed = Signal(int) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioFloat") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setFixedWidth(152) + lay = QVBoxLayout(self) + lay.setContentsMargins(10, 8, 11, 8) + lay.setSpacing(6) + self.bright = self._row(lay, "Bright") + self.contr = self._row(lay, "Contr") + self.bright.valueChanged.connect(self.brightness_changed) + self.contr.valueChanged.connect(self.contrast_changed) + self._tok = theme.studio_tokens() + + def _row(self, parent_layout, label): + row = QHBoxLayout() + row.setSpacing(8) + lab = QLabel(label, self) + lab.setObjectName("studioFloatLabel") + lab.setFixedWidth(34) + track = TealTrack(50, self) + track.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + row.addWidget(lab) + row.addWidget(track, 1) + parent_layout.addLayout(row) + return track + + def set_tokens(self, tokens): + self._tok = tokens + self.bright.set_tokens(tokens) + self.contr.set_tokens(tokens) + + def set_values(self, brightness, contrast): + self.bright.setValue(brightness) + self.contr.setValue(contrast) + + +class CanvasArea(QWidget): + """The dark canvas; hosts the glassy floats and (later) the real field.""" + + section_changed = Signal(int) + brightness_changed = Signal(int) + contrast_changed = Signal(int) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioCanvas") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setMinimumSize(420, 320) + self._tok = theme.studio_tokens() + self._canvas_widget = None + + self.badge = ActiveObjectBadge(self) + self.scalebar = ScaleBar(self) + self.secnav = SectionNavPill(self) + self.bc = BrightnessContrastCard(self) + + self.secnav.stepped.connect(self.section_changed) + self.bc.brightness_changed.connect(self.brightness_changed) + self.bc.contrast_changed.connect(self.contrast_changed) + + self.badge.set_object("spine_042", "review") + + # --- public API ------------------------------------------------------- + def set_canvas_widget(self, widget): + """Mount a real canvas (the field widget); the demo backdrop yields.""" + if self._canvas_widget is not None: + self._canvas_widget.setParent(None) + self._canvas_widget = widget + if widget is not None: + widget.setParent(self) + widget.lower() + widget.show() + self._reposition() + self.update() + + def set_active_object(self, name, status): + self.badge.set_object(name, status) + self._reposition() + + def set_section(self, idx, total): + self.secnav.set_section(idx, total) + self._reposition() + + def set_scale(self, text): + self.scalebar.set_text(text) + self._reposition() + + def set_brightness(self, value): + self.bc.bright.setValue(value) + + def set_contrast(self, value): + self.bc.contr.setValue(value) + + def apply_theme(self, theme_name=None): + self._tok = theme.studio_tokens(theme_name) + for w in (self.badge, self.scalebar, self.secnav, self.bc): + w.set_tokens(self._tok) + self.update() + + # --- layout ----------------------------------------------------------- + def resizeEvent(self, event): + super().resizeEvent(event) + self._reposition() + + def showEvent(self, event): + super().showEvent(event) + self._reposition() + + def _reposition(self): + w, h = self.width(), self.height() + if self._canvas_widget is not None: + self._canvas_widget.setGeometry(0, 0, w, h) + for f in (self.badge, self.scalebar, self.secnav, self.bc): + f.adjustSize() + self.badge.move(14, 14) + self.scalebar.move(16, h - self.scalebar.height() - 16) + self.secnav.move((w - self.secnav.width()) // 2, h - self.secnav.height() - 14) + self.bc.move(w - self.bc.width() - 14, h - self.bc.height() - 14) + for f in (self.badge, self.scalebar, self.secnav, self.bc): + f.raise_() + + # --- representative backdrop (until the real field mounts) ------------ + def paintEvent(self, _event): + if self._canvas_widget is not None: + return + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + w, h = self.width(), self.height() + tok = self._tok + p.fillRect(self.rect(), QColor(tok["canvas_bg"])) + + # faint checker, like the mockup's tiled ground + step = 22 + p.setPen(Qt.NoPen) + p.setBrush(QColor(tok["canvas_bg_2"])) + for yy in range(0, h, step): + for xx in range(0, w, step): + if ((xx // step) + (yy // step)) % 2 == 0: + p.drawRect(xx, yy, step, step) + + # Demo backdrop hues below are representative EM/segmentation imagery — + # NOT themeable chrome and NOT data the panel reads; they only exist + # until set_canvas_widget() mounts the real field. + # soft EM-ish blobs + for fx, fy, fr, c in ( + (0.40, 0.44, 0.20, "#5b6675"), + (0.66, 0.60, 0.26, "#474f5b"), + (0.54, 0.30, 0.15, "#6a7280"), + ): + cx, cy, r = w * fx, h * fy, min(w, h) * fr + g = QRadialGradient(cx, cy, r) + g.setColorAt(0.0, QColor(c)) + base = QColor(c) + base.setAlpha(0) + g.setColorAt(1.0, base) + p.setBrush(QBrush(g)) + p.drawEllipse(QPointF(cx, cy), r, r * 0.78) + + # translucent segmentation ellipses in data colors (screen blend) + p.setCompositionMode(QPainter.CompositionMode_Screen) + for fx, fy, fw, fh, c in ( + (0.30, 0.40, 0.22, 0.30, "#37c0a6"), + (0.58, 0.56, 0.30, 0.34, "#5ab0f0"), + (0.48, 0.26, 0.16, 0.20, "#f0a6d8"), + (0.64, 0.36, 0.13, 0.17, "#f4bd4f"), + ): + col = QColor(c) + col.setAlpha(120) + p.setBrush(col) + p.setPen(Qt.NoPen) + p.drawEllipse(QRectF(w * fx, h * fy, w * fw, h * fh)) + p.setCompositionMode(QPainter.CompositionMode_SourceOver) + p.end() diff --git a/PyReconstruct/modules/gui/studio/objects_panel.py b/PyReconstruct/modules/gui/studio/objects_panel.py new file mode 100644 index 00000000..6b4e9243 --- /dev/null +++ b/PyReconstruct/modules/gui/studio/objects_panel.py @@ -0,0 +1,295 @@ +"""The Objects panel — the promoted, first-class, filterable object list. + +268px wide, ``panel`` ground. A tracked uppercase ``OBJECTS`` header with a mono +count; a filter field; a model/view list whose rows read +``[swatch] [name · type] …… [status dot]``; and a curation legend in the footer. + +The list is a ``QListView`` over a lightweight model with a custom delegate, so +it virtualizes (only visible rows are painted) and scales to thousands of +objects. The delegate honours the spec's row anatomy precisely: the data color +is a small *swatch* (never tinted text), the type is a faint mono suffix, the +curation status is a colored dot, and the selected row carries a teal left bar +over a wash that fades to nothing. + +Pure view layer: feed it dicts via :meth:`set_objects` +(``{"name", "type", "color", "status"}``); it emits :attr:`object_activated`. +""" +from PySide6.QtCore import ( + Qt, Signal, QAbstractListModel, QModelIndex, QSortFilterProxyModel, QSize, QRectF, +) +from PySide6.QtGui import QColor, QBrush, QLinearGradient, QPainter, QPen +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QListView, + QStyledItemDelegate, QStyle, +) + +from ..utils import theme, icons + +_OBJ_ROLE = Qt.UserRole + 1 +#: curation status -> chrome token key for the status dot +_STATUS_TOKEN = {"curated": "ok", "review": "warn", "flagged": "bad"} +_ROW_H = 30 + + +class _ObjectModel(QAbstractListModel): + """Holds the object dicts; exposes each row's dict under ``_OBJ_ROLE``.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._objs = [] + + def set_objects(self, objs): + self.beginResetModel() + self._objs = list(objs) + self.endResetModel() + + def rowCount(self, parent=QModelIndex()): + return 0 if parent.isValid() else len(self._objs) + + def data(self, index, role=Qt.DisplayRole): + if not index.isValid(): + return None + obj = self._objs[index.row()] + if role == _OBJ_ROLE: + return obj + if role == Qt.DisplayRole: + return obj.get("name", "") + return None + + +class _ObjectFilter(QSortFilterProxyModel): + """Case-insensitive substring filter over name + type.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._needle = "" + + def set_needle(self, text): + self._needle = (text or "").strip().lower() + self.invalidateRowsFilter() + + def filterAcceptsRow(self, row, parent): + if not self._needle: + return True + obj = self.sourceModel().index(row, 0, parent).data(_OBJ_ROLE) or {} + hay = f"{obj.get('name', '')} {obj.get('type', '')}".lower() + return self._needle in hay + + +class _ObjectRowDelegate(QStyledItemDelegate): + """Paints one object row per the spec's anatomy.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._tok = theme.studio_tokens() + self._mono = QLabel().font() # cloned below in paint via setFamily + self._mono.setStyleHint(self._mono.StyleHint.Monospace) + self._mono.setFamily("DejaVu Sans Mono") + + def set_tokens(self, tokens): + self._tok = tokens + + def sizeHint(self, option, index): + return QSize(option.rect.width(), _ROW_H) + + def paint(self, painter, option, index): + obj = index.data(_OBJ_ROLE) or {} + tok = self._tok + rect = option.rect + painter.save() + painter.setRenderHint(QPainter.Antialiasing, True) + + selected = bool(option.state & QStyle.State_Selected) + if selected: + grad = QLinearGradient(rect.left(), 0, rect.right(), 0) + grad.setColorAt(0.0, QColor(tok["row_sel_a"])) + grad.setColorAt(1.0, QColor(0, 0, 0, 0)) + painter.fillRect(rect, QBrush(grad)) + painter.fillRect(rect.left(), rect.top(), 2, rect.height(), + QColor(tok["row_sel_bar"])) + elif option.state & QStyle.State_MouseOver: + hov = QColor(tok["row_sel_a"]) + hov.setAlpha(90) + painter.fillRect(rect, hov) + + # per-row hairline (border-bottom) + painter.setPen(QPen(QColor(tok["line_soft"]), 1)) + painter.drawLine(rect.left(), rect.bottom(), rect.right(), rect.bottom()) + + pad = 11 + # data-color swatch (11x11, radius 3) — NEVER tinted text + sw = 11 + sw_x = rect.left() + pad + sw_y = rect.center().y() - sw // 2 + 1 + painter.setPen(QPen(QColor(255, 255, 255, 28), 1)) + painter.setBrush(QColor(obj.get("color", "#888888"))) + painter.drawRoundedRect(QRectF(sw_x, sw_y, sw, sw), 3, 3) + + # curation status dot (8px), right-aligned + dot = 8 + dot_x = rect.right() - pad - dot + status = obj.get("status") + if status in _STATUS_TOKEN: + painter.setPen(Qt.NoPen) + painter.setBrush(QColor(tok[_STATUS_TOKEN[status]])) + painter.drawEllipse(QRectF(dot_x, rect.center().y() - dot / 2, dot, dot)) + + # name (ink) + " · type" (faint mono), elided to fit + name_x = sw_x + sw + 8 + avail = dot_x - 8 - name_x + name_font = option.font + type_font = self._mono + type_font.setPointSizeF(max(7.5, name_font.pointSizeF() - 1.5) + if name_font.pointSizeF() > 0 else 8.5) + painter.setFont(type_font) + type_fm = painter.fontMetrics() + type_text = f" · {obj['type']}" if obj.get("type") else "" + type_w = type_fm.horizontalAdvance(type_text) + + painter.setFont(name_font) + name_fm = painter.fontMetrics() + name = name_fm.elidedText(obj.get("name", ""), Qt.ElideRight, max(0, avail - type_w)) + name_w = name_fm.horizontalAdvance(name) + baseline = rect.center().y() + name_fm.ascent() // 2 - 1 + painter.setPen(QColor(tok["ink"])) + painter.drawText(name_x, baseline, name) + if type_text: + painter.setFont(type_font) + painter.setPen(QColor(tok["faint"])) + painter.drawText(name_x + name_w, baseline, type_text) + + painter.restore() + + +def _dot(color_hex, d=8): + """A small colored status dot as a QLabel (for the legend).""" + lab = QLabel() + lab.setFixedSize(d, d) + lab.setStyleSheet(f"background:{color_hex}; border-radius:{d // 2}px;") + return lab + + +class ObjectsPanel(QWidget): + """First-class Objects list panel; emits :attr:`object_activated`.""" + + object_activated = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioObjectsPanel") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setFixedWidth(268) + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # header: TITLE + mono count + header = QWidget(self) + header.setObjectName("studioPanelHeader") + header.setAttribute(Qt.WA_StyledBackground, True) + hl = QHBoxLayout(header) + hl.setContentsMargins(11, 8, 11, 8) + self._title = QLabel("OBJECTS", header) + self._title.setObjectName("studioPanelTitle") + self._count = QLabel("0", header) + self._count.setObjectName("studioPanelCount") + hl.addWidget(self._title) + hl.addStretch(1) + hl.addWidget(self._count) + root.addWidget(header) + + # filter field (with a vector magnifier, never a unicode glyph) + filt_wrap = QWidget(self) + fwl = QVBoxLayout(filt_wrap) + fwl.setContentsMargins(11, 8, 11, 8) + self.filter = QLineEdit(filt_wrap) + self.filter.setObjectName("studioFilter") + self.filter.setPlaceholderText("Filter objects…") + self.filter.setClearButtonEnabled(True) + self._search_action = self.filter.addAction( + icons.tool_icon("search", 16, theme.studio_tokens()["faint"]), + QLineEdit.LeadingPosition, + ) + fwl.addWidget(self.filter) + root.addWidget(filt_wrap) + + # the list (model / filter / view + custom delegate) + self._model = _ObjectModel(self) + self._proxy = _ObjectFilter(self) + self._proxy.setSourceModel(self._model) + self.view = QListView(self) + self.view.setObjectName("studioObjectList") + self.view.setModel(self._proxy) + self._delegate = _ObjectRowDelegate(self.view) + self.view.setItemDelegate(self._delegate) + self.view.setMouseTracking(True) + self.view.setUniformItemSizes(True) + self.view.setSelectionMode(QListView.SingleSelection) + self.view.setVerticalScrollMode(QListView.ScrollPerPixel) + self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + root.addWidget(self.view, 1) + + # legend footer: Curated / Review / Flagged + self._legend = QWidget(self) + self._legend.setObjectName("studioLegend") + self._legend.setAttribute(Qt.WA_StyledBackground, True) + ll = QHBoxLayout(self._legend) + ll.setContentsMargins(11, 8, 11, 8) + ll.setSpacing(11) + self._legend_dots = [] + for label, tok_key in (("Curated", "ok"), ("Review", "warn"), ("Flagged", "bad")): + cell = QHBoxLayout() + cell.setSpacing(5) + dot = _dot(theme.studio_tokens()[tok_key]) + self._legend_dots.append((dot, tok_key)) + text = QLabel(label, self._legend) + text.setObjectName("studioLegendLabel") + cell.addWidget(dot) + cell.addWidget(text) + ll.addLayout(cell) + ll.addStretch(1) + root.addWidget(self._legend) + + # wiring + self.filter.textChanged.connect(self._on_filter) + self.view.selectionModel().currentChanged.connect(self._on_current) + + # --- public API ------------------------------------------------------- + def set_title(self, title): + """Repoint the panel (e.g. OBJECTS / TRACES / SECTIONS / FLAGS).""" + self._title.setText(title.upper()) + + def set_objects(self, objs): + """Replace the list contents (a list of object dicts).""" + self._model.set_objects(objs) + self._update_count() + if self._proxy.rowCount(): + self.view.setCurrentIndex(self._proxy.index(0, 0)) + + def selected_name(self): + idx = self.view.currentIndex() + obj = idx.data(_OBJ_ROLE) if idx.isValid() else None + return obj.get("name") if obj else None + + def apply_theme(self, theme_name=None): + """Re-tint the data-driven bits after a theme change.""" + tok = theme.studio_tokens(theme_name) + self._delegate.set_tokens(tok) + self._search_action.setIcon(icons.tool_icon("search", 16, tok["faint"])) + for dot, key in self._legend_dots: + dot.setStyleSheet(f"background:{tok[key]}; border-radius:4px;") + self.view.viewport().update() + + # --- internals -------------------------------------------------------- + def _on_filter(self, text): + self._proxy.set_needle(text) + self._update_count() + + def _on_current(self, current, _previous): + obj = current.data(_OBJ_ROLE) if current.isValid() else None + if obj: + self.object_activated.emit(obj.get("name", "")) + + def _update_count(self): + self._count.setText(f"{self._proxy.rowCount():,}") diff --git a/PyReconstruct/modules/gui/studio/palette_strip.py b/PyReconstruct/modules/gui/studio/palette_strip.py new file mode 100644 index 00000000..efd1b830 --- /dev/null +++ b/PyReconstruct/modules/gui/studio/palette_strip.py @@ -0,0 +1,130 @@ +"""The trace-palette strip — a real palette across the bottom of the main column. + +A tracked ``PALETTE`` label, a row of 30px data-color swatches (the active one +ringed with a teal outline), a dashed ``+`` add tile, and a right-aligned mono +readout of the active trace (``circle · r=0.045``). The swatch colors are *data* +(trace colors), never chrome. +""" +from PySide6.QtCore import Qt, Signal, QRectF +from PySide6.QtGui import QPainter, QColor, QPen +from PySide6.QtWidgets import ( + QWidget, QHBoxLayout, QLabel, QToolButton, +) + +from ..utils import theme + +_SW = 30 # the swatch itself +_BOX = 34 # widget footprint (leaves room for the offset active outline) + + +class _Swatch(QWidget): + """A single 30px data-color swatch; teal-outlined when active.""" + + clicked = Signal(int) + + def __init__(self, index, color_hex, parent=None): + super().__init__(parent) + self._index = index + self._color = color_hex + self._active = False + self._accent = "#37c0a6" + self.setFixedSize(_BOX, _BOX) + self.setCursor(Qt.PointingHandCursor) + + def set_active(self, on): + self._active = bool(on) + self.update() + + def set_accent(self, accent): + self._accent = accent + self.update() + + def mousePressEvent(self, _event): + self.clicked.emit(self._index) + + def paintEvent(self, _event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + fill = QRectF((_BOX - _SW) / 2, (_BOX - _SW) / 2, _SW, _SW) + p.setPen(QPen(QColor(255, 255, 255, 20), 1)) + p.setBrush(QColor(self._color)) + p.drawRoundedRect(fill, 8, 8) + if self._active: + p.setBrush(Qt.NoBrush) + p.setPen(QPen(QColor(self._accent), 2)) + p.drawRoundedRect(QRectF(1, 1, _BOX - 2, _BOX - 2), 9, 9) + p.end() + + +class PaletteStrip(QWidget): + """Bottom palette strip; emits :attr:`color_selected` / :attr:`add_requested`.""" + + color_selected = Signal(int) + add_requested = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioPaletteStrip") + self.setAttribute(Qt.WA_StyledBackground, True) + self._swatches = [] + self._active = 0 + self._accent = theme.studio_tokens()["accent"] + + self._row = QHBoxLayout(self) + self._row.setContentsMargins(13, 7, 13, 7) + self._row.setSpacing(11) + + self._label = QLabel("PALETTE", self) + self._label.setObjectName("studioPaletteLabel") + self._row.addWidget(self._label) + + self._swatch_row = QHBoxLayout() + self._swatch_row.setSpacing(6) + self._row.addLayout(self._swatch_row) + + self._add = QToolButton(self) + self._add.setObjectName("studioAddSwatch") + self._add.setText("+") + self._add.setFixedSize(_SW, _SW) + self._add.setCursor(Qt.PointingHandCursor) + self._add.setFocusPolicy(Qt.NoFocus) + self._add.clicked.connect(self.add_requested) + self._swatch_row.addWidget(self._add) + + self._row.addStretch(1) + + self._readout = QLabel("", self) + self._readout.setObjectName("studioPaletteReadout") + self._row.addWidget(self._readout) + + def set_colors(self, colors): + """Replace the swatch colors (a list of hex strings).""" + for sw in self._swatches: + self._swatch_row.removeWidget(sw) + sw.deleteLater() + self._swatches = [] + for i, color in enumerate(colors): + sw = _Swatch(i, color, self) + sw.set_accent(self._accent) + sw.clicked.connect(self._on_click) + # keep the add tile last + self._swatch_row.insertWidget(self._swatch_row.count() - 1, sw) + self._swatches.append(sw) + self.set_active(min(self._active, len(colors) - 1) if colors else 0) + + def set_active(self, index): + self._active = index + for i, sw in enumerate(self._swatches): + sw.set_active(i == index) + + def set_readout(self, text): + self._readout.setText(text) + + def apply_theme(self, theme_name=None): + self._accent = theme.studio_tokens(theme_name)["accent"] + for sw in self._swatches: + sw.set_accent(self._accent) + + def _on_click(self, index): + self.set_active(index) + self.color_selected.emit(index) diff --git a/PyReconstruct/modules/gui/studio/qss.py b/PyReconstruct/modules/gui/studio/qss.py new file mode 100644 index 00000000..bba9f651 --- /dev/null +++ b/PyReconstruct/modules/gui/studio/qss.py @@ -0,0 +1,211 @@ +"""Chrome stylesheet for the Studio layout widgets. + +One source of styling truth for the bespoke widgets, driven entirely by +:func:`PyReconstruct.modules.gui.utils.theme.studio_tokens`. The same template +renders both shells — the Studio (dark) and Atlas (light) tokens differ, the +selectors do not — so a theme toggle is just "re-build and re-apply". + +Set on :class:`~.shell.StudioShell`, it cascades to every child by objectName. +It styles container chrome, labels and buttons (resting + active); the data-ish +bits (object-row swatches, palette swatches, the brand mark, slider tracks, +status dots) are custom-painted in the widgets from the same tokens. + +The template uses ``string.Template`` ``${token}`` placeholders so QSS's literal +``{ }`` braces need no escaping. Active button states use the dynamic ``active`` +property (``QToolButton[active="true"]``); widgets repolish on toggle. +""" +from string import Template + +from ..utils import theme + + +# rem→px uses the mockup's 14px root. Kept inline as literals for legibility. +_TEMPLATE = Template(""" +/* ---- shell ground -------------------------------------------------------- */ +#studioShell, #studioBody, #studioMainColumn, #studioWorkRow { background: ${bg}; } + +/* qdarkstyle paints every QLabel with the theme ground (light in Atlas), which + would show through our dark glass floats; make labels transparent by default + so they take their parent's surface. Labels that need a fill (chip, kbd, + status dots) override this with an id selector or their own stylesheet. */ +QLabel { background: transparent; padding: 0px; margin: 0px; } + +/* ---- title strip --------------------------------------------------------- */ +#studioTitleStrip { + background: ${panel_2}; + border-bottom: 1px solid ${line}; +} +#studioBrand { color: ${ink}; font-weight: 800; font-size: 14px; } +#studioBrandAccent { color: ${accent}; font-weight: 800; font-size: 14px; } +QLabel#studioMenuItem { color: ${muted}; font-size: 12px; padding: 3px 7px; border-radius: 6px; } +QLabel#studioMenuItem:hover { color: ${ink}; background: ${raised}; } +#studioChip { + color: ${chip_ink}; + background: ${chip_bg}; + border: 1px solid ${chip_border}; + border-radius: 10px; + padding: 2px 9px; + font-size: 11px; + font-weight: 600; +} +#studioCmdk { + color: ${faint}; + background: ${bg}; + border: 1px solid ${line}; + border-radius: 7px; + padding: 4px 9px; + font-size: 11px; +} +#studioCmdkKbd { + color: ${muted}; + background: ${raised}; + border: 1px solid ${line}; + border-radius: 4px; + padding: 0px 5px; + font-family: "DejaVu Sans Mono", "Cascadia Code", Menlo, Consolas, monospace; + font-size: 10px; +} + +/* ---- activity rail ------------------------------------------------------- */ +#studioActivityRail { + background: ${panel_2}; + border-right: 1px solid ${line}; +} +QToolButton#studioRailButton { + background: transparent; + border: 1px solid transparent; + border-radius: 9px; +} +QToolButton#studioRailButton:hover { background: ${raised}; } +QToolButton#studioRailButton[active="true"] { + background: ${rail_active_bg}; + border: 1px solid ${rail_active_ring}; +} + +/* ---- objects panel ------------------------------------------------------- */ +#studioObjectsPanel { + background: ${panel}; + border-right: 1px solid ${line}; +} +#studioPanelHeader { border-bottom: 1px solid ${line_soft}; } +#studioPanelTitle { + color: ${muted}; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.6px; +} +#studioPanelCount { + color: ${faint}; + font-size: 10px; + font-family: "DejaVu Sans Mono", "Cascadia Code", Menlo, Consolas, monospace; +} +QLineEdit#studioFilter { + color: ${ink}; + background: ${bg}; + border: 1px solid ${line}; + border-radius: 8px; + padding: 5px 8px; + font-size: 11px; + selection-background-color: ${accent}; + selection-color: ${on_accent}; +} +QLineEdit#studioFilter:focus { border: 1px solid ${accent}; } +QListView#studioObjectList { + background: ${panel}; + border: none; + outline: none; +} +#studioLegend { border-top: 1px solid ${line_soft}; } +#studioLegendLabel { color: ${faint}; font-size: 10px; } + +/* ---- canvas + glassy floats ---------------------------------------------- */ +#studioCanvas { background: ${canvas_bg}; } +QFrame#studioFloat { + background: ${float_bg}; + border: 1px solid ${float_line}; + border-radius: 10px; +} +#studioFloatText { color: ${float_ink}; font-size: 10px; } +#studioFloatLabel { color: ${float_faint}; font-size: 10px; } +#studioScaleText { + color: ${scale_ink}; + font-size: 10px; + font-family: "DejaVu Sans Mono", "Cascadia Code", Menlo, Consolas, monospace; +} +#studioSecnavLabel { + color: ${float_ink}; + font-size: 11px; + font-family: "DejaVu Sans Mono", "Cascadia Code", Menlo, Consolas, monospace; +} +#studioSecnavTotal { color: ${float_faint}; } +QToolButton#studioSecnavButton { + color: ${float_muted}; + background: ${raised_dark}; + border: none; + border-radius: 7px; +} +QToolButton#studioSecnavButton:hover { color: ${float_ink}; } + +/* ---- tool rail (flush; no card, no blur, no shadow, no glow) -------------- */ +#studioToolRail { + background: ${panel_2}; + border-left: 1px solid ${line}; +} +QToolButton#studioToolButton { + background: transparent; + border: 1px solid transparent; + border-radius: 10px; +} +QToolButton#studioToolButton:hover { background: ${raised}; } +QToolButton#studioToolButton[active="true"] { + background: ${tool_active_bg}; + border: 1px solid ${tool_active_border}; +} + +/* ---- palette strip ------------------------------------------------------- */ +#studioPaletteStrip { + background: ${panel}; + border-top: 1px solid ${line}; +} +#studioPaletteLabel { + color: ${muted}; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.6px; +} +QToolButton#studioAddSwatch { + color: ${faint}; + background: ${raised}; + border: 1px dashed ${line}; + border-radius: 8px; + font-size: 15px; +} +QToolButton#studioAddSwatch:hover { color: ${ink}; border: 1px dashed ${accent}; } +#studioPaletteReadout { + color: ${faint}; + font-size: 11px; + font-family: "DejaVu Sans Mono", "Cascadia Code", Menlo, Consolas, monospace; +} + +/* ---- status bar ---------------------------------------------------------- */ +#studioStatusBar { + background: ${panel_2}; + border-top: 1px solid ${line}; +} +#studioStatusText { + color: ${muted}; + font-size: 10px; + font-family: "DejaVu Sans Mono", "Cascadia Code", Menlo, Consolas, monospace; +} +#studioStatusValue { + color: ${accent}; + font-size: 10px; + font-family: "DejaVu Sans Mono", "Cascadia Code", Menlo, Consolas, monospace; +} +""") + + +def chrome_qss(theme_name=None, app=None) -> str: + """Build the Studio chrome stylesheet for a theme (or the active one).""" + tokens = theme.studio_tokens(theme_name, app) + return _TEMPLATE.substitute(tokens) diff --git a/PyReconstruct/modules/gui/studio/rail.py b/PyReconstruct/modules/gui/studio/rail.py new file mode 100644 index 00000000..57f560ee --- /dev/null +++ b/PyReconstruct/modules/gui/studio/rail.py @@ -0,0 +1,75 @@ +"""The activity rail — the slim left strip that switches the left panel. + +48px wide, ``panel-2`` ground, a column of 34x34 icon tiles (Objects, Traces, +Sections, Flags, 3D scene, then a spacer, then Settings). The active tile is +teal-tinted with an inset teal ring; clicking one emits :attr:`activated` so the +shell can repoint the Objects panel (or launch the 3D panel). +""" +from PySide6.QtCore import Signal, Qt +from PySide6.QtWidgets import QWidget, QVBoxLayout + +from ._common import IconTileButton + +#: (key, tooltip, icon_svg_name) in rail order; ``None`` is the flexible spacer +RAIL_ITEMS = [ + ("objects", "Objects", "objects"), + ("traces", "Traces", "traces"), + ("sections", "Sections", "sections"), + ("flags", "Flags", "flag"), + ("scene3d", "3D scene", "scene3d"), + None, + ("settings", "Settings", "settings"), +] + +_TILE_PX = 34 +_ICON_PX = 20 + + +class ActivityRail(QWidget): + """Vertical icon rail; emits :attr:`activated` with the selected item key.""" + + activated = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioActivityRail") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setFixedWidth(48) + self._buttons = {} + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 8, 0, 8) + layout.setSpacing(4) + layout.setAlignment(Qt.AlignHCenter | Qt.AlignTop) + + for item in RAIL_ITEMS: + if item is None: + layout.addStretch(1) + continue + key, tip, icon_name = item + btn = IconTileButton(key, icon_name, "studioRailButton", _TILE_PX, _ICON_PX, self) + btn.setToolTip(tip) + btn.clicked.connect(lambda _=False, k=key: self._on_click(k)) + layout.addWidget(btn, 0, Qt.AlignHCenter) + self._buttons[key] = btn + + self.set_active("objects") + + def _on_click(self, key): + self.set_active(key) + self.activated.emit(key) + + def set_active(self, key): + """Light the tile for ``key`` (no-op if unknown); clears the others.""" + if key not in self._buttons: + return + for k, btn in self._buttons.items(): + btn.set_active(k == key) + self._active_key = key + + def active_key(self): + return getattr(self, "_active_key", None) + + def retint(self, rest_color, active_color): + for btn in self._buttons.values(): + btn.retint(rest_color, active_color) diff --git a/PyReconstruct/modules/gui/studio/shell.py b/PyReconstruct/modules/gui/studio/shell.py new file mode 100644 index 00000000..8e148a61 --- /dev/null +++ b/PyReconstruct/modules/gui/studio/shell.py @@ -0,0 +1,241 @@ +"""StudioShell — the composite that assembles the Studio layout grammar. + +It lays out the regions exactly as the spec's grammar describes :: + + title strip (full width) + body: [ activity rail | main column ] + main column: [ objects panel | canvas | tool rail ] (the "work row") + [ palette strip ] + status bar (full width) + +and wires enough behavior to be demonstrable: the rail repoints the left panel, +the object list filters and reports selection, selecting an object updates the +in-canvas badge, the section pill steps sections, the tool rail and palette +select, and :meth:`apply_theme` toggles Studio ↔ Atlas (same layout, light/dark +shell, always-dark canvas). + +It is a plain ``QWidget`` so it can be a standalone preview window *or* the main +window's central widget. :meth:`demo` returns one populated with the mockup's +content (including a 3,809-row object list, to prove the list virtualizes). +""" +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QApplication + +from ..utils import theme +from . import qss +from .title_strip import StudioTitleStrip +from .rail import ActivityRail +from .objects_panel import ObjectsPanel +from .canvas import CanvasArea +from .tool_rail import ToolRail +from .palette_strip import PaletteStrip +from .status_bar import StudioStatusBar + + +class StudioShell(QWidget): + """The whole Studio layout, assembled from the region widgets.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioShell") + self.setAttribute(Qt.WA_StyledBackground, True) + self._theme = None + self._section_idx, self._section_total = 148, 287 + self._datasets = {} + self._by_name = {} + + # regions + self.title_strip = StudioTitleStrip(self) + self.rail = ActivityRail(self) + self.objects_panel = ObjectsPanel(self) + self.canvas = CanvasArea(self) + self.tool_rail = ToolRail(self) + self.palette_strip = PaletteStrip(self) + self.status_bar = StudioStatusBar(self) + + # assemble + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + root.addWidget(self.title_strip) + + body = QWidget(self) + body.setObjectName("studioBody") + body.setAttribute(Qt.WA_StyledBackground, True) + body_l = QHBoxLayout(body) + body_l.setContentsMargins(0, 0, 0, 0) + body_l.setSpacing(0) + body_l.addWidget(self.rail) + + main_col = QWidget(body) + main_col.setObjectName("studioMainColumn") + main_col.setAttribute(Qt.WA_StyledBackground, True) + main_l = QVBoxLayout(main_col) + main_l.setContentsMargins(0, 0, 0, 0) + main_l.setSpacing(0) + + work = QWidget(main_col) + work.setObjectName("studioWorkRow") + work.setAttribute(Qt.WA_StyledBackground, True) + work_l = QHBoxLayout(work) + work_l.setContentsMargins(0, 0, 0, 0) + work_l.setSpacing(0) + work_l.addWidget(self.objects_panel) + work_l.addWidget(self.canvas, 1) + work_l.addWidget(self.tool_rail) + + main_l.addWidget(work, 1) + main_l.addWidget(self.palette_strip) + body_l.addWidget(main_col, 1) + + root.addWidget(body, 1) + root.addWidget(self.status_bar) + + self._wire() + self.apply_theme("studio", app_wide=False) + + # --- wiring ----------------------------------------------------------- + def _wire(self): + self.rail.activated.connect(self._on_rail) + self.objects_panel.object_activated.connect(self._on_object) + self.canvas.section_changed.connect(self._on_section_step) + + def _on_rail(self, key): + # the four list panels repoint the left panel; 3D / Settings are seams + # for follow-up components (the panel stays put, the rail item lights). + if key in self._datasets: + self.objects_panel.set_title(key) + self._set_dataset(key) + + def _on_object(self, name): + status = self._by_name.get(name) + if status: + self.canvas.set_active_object(name, status) + + def _on_section_step(self, delta): + self._section_idx = max(1, min(self._section_total, self._section_idx + delta)) + self.canvas.set_section(self._section_idx, self._section_total) + self.status_bar.set_section(self._section_idx) + + def _set_dataset(self, key): + objs = self._datasets.get(key, []) + self._by_name = {o["name"]: o.get("status") for o in objs} + self.objects_panel.set_objects(objs) + + # --- theming ---------------------------------------------------------- + def apply_theme(self, theme_name=None, app_wide=True): + """Apply Studio (dark) or Atlas (light): same layout, different shell. + + ``app_wide`` also re-applies the qdarkstyle-remap base app stylesheet + (for a standalone preview); the main window owns that when it adopts the + shell, so it can pass ``app_wide=False``. + """ + self._theme = theme_name + if app_wide: + try: + theme.apply_theme(QApplication.instance(), theme_name) + except Exception: + pass + self.setStyleSheet(qss.chrome_qss(theme_name)) + tok = theme.studio_tokens(theme_name) + self.rail.retint(tok["faint"], tok["accent"]) + self.tool_rail.retint(tok["tool_icon_rest"], tok["tool_icon_active"]) + self.objects_panel.apply_theme(theme_name) + self.canvas.apply_theme(theme_name) + self.palette_strip.apply_theme(theme_name) + self.status_bar.apply_theme(theme_name) + + def current_theme(self): + return self._theme + + # --- demo content ----------------------------------------------------- + @classmethod + def demo(cls, parent=None): + """A shell populated with the mockup's content for preview / tests.""" + shell = cls(parent) + shell._datasets = { + "objects": _demo_objects(), + "traces": _demo_traces(), + "sections": _demo_sections(), + "flags": _demo_flags(), + } + shell.objects_panel.set_title("objects") + shell._set_dataset("objects") + shell.palette_strip.set_colors(_PALETTE) + shell.palette_strip.set_active(0) + shell.palette_strip.set_readout("circle · r=0.045") + shell.canvas.set_section(148, 287) + shell.canvas.set_active_object("spine_042", "review") + shell.canvas.set_brightness(38) + shell.canvas.set_contrast(60) + shell.status_bar.set_section(148) + shell.status_bar.set_alignment("default") + shell.status_bar.set_position("0.8072", "0.8439") + shell.status_bar.set_traces("61,121") + shell.status_bar.set_zoom("240%") + shell.rail.set_active("objects") + shell.tool_rail.set_active("pointer") + return shell + + +# --- demo data (the mockup's content) ---------------------------------------- +#: the mockup's palette swatches (trace colors — data, not chrome) +_PALETTE = ["#6ce0c4", "#f0a6d8", "#9bd35a", "#f4bd4f", + "#5ab0f0", "#b06cf0", "#f07a72", "#37c0a6"] + +#: the eight named rows shown in the mockup, verbatim +_NAMED = [ + {"name": "d001", "type": "dendrite", "color": "#6ce0c4", "status": "curated"}, + {"name": "spine_042", "type": "spine", "color": "#f0a6d8", "status": "review"}, + {"name": "axon_7", "type": "axon", "color": "#9bd35a", "status": "curated"}, + {"name": "psd_018", "type": "synapse", "color": "#f4bd4f", "status": "flagged"}, + {"name": "mito_113", "type": "mitochondrion", "color": "#5ab0f0", "status": "curated"}, + {"name": "autoseg_2207", "type": "", "color": "#b06cf0", "status": "review"}, + {"name": "autoseg_2208", "type": "", "color": "#f07a72", "status": "curated"}, + {"name": "autoseg_2209", "type": "", "color": "#37c0a6", "status": "review"}, +] + + +def _demo_objects(total=3809): + """The eight named rows, then autoseg_NNNN filler up to ``total`` rows. + + The count reads like the mockup's 3,809 and the list is long enough to prove + the model/view only paints the visible rows (virtualization). + """ + objs = [dict(o) for o in _NAMED] + statuses = ("curated", "review", "curated", "flagged", "curated", "review") + start = 2210 + for i in range(total - len(objs)): + objs.append({ + "name": f"autoseg_{start + i}", + "type": "", + "color": _PALETTE[i % len(_PALETTE)], + "status": statuses[i % len(statuses)], + }) + return objs + + +def _demo_traces(): + return [ + {"name": "d001 · c12", "type": "closed", "color": "#6ce0c4", "status": "curated"}, + {"name": "spine_042 · c3", "type": "closed", "color": "#f0a6d8", "status": "review"}, + {"name": "axon_7 · c44", "type": "open", "color": "#9bd35a", "status": "curated"}, + {"name": "psd_018 · c1", "type": "closed", "color": "#f4bd4f", "status": "flagged"}, + {"name": "mito_113 · c8", "type": "closed", "color": "#5ab0f0", "status": "curated"}, + ] + + +def _demo_sections(): + out = [] + for n in range(146, 152): + out.append({"name": f"section {n}", "type": "0.004 µm/px", + "color": "#5ab0f0", "status": "curated"}) + return out + + +def _demo_flags(): + return [ + {"name": "check merge", "type": "psd_018", "color": "#f07a72", "status": "flagged"}, + {"name": "verify split", "type": "autoseg_2207", "color": "#f4bd4f", "status": "review"}, + {"name": "re-trace", "type": "spine_042", "color": "#f4bd4f", "status": "review"}, + ] diff --git a/PyReconstruct/modules/gui/studio/status_bar.py b/PyReconstruct/modules/gui/studio/status_bar.py new file mode 100644 index 00000000..6cbe8ecf --- /dev/null +++ b/PyReconstruct/modules/gui/studio/status_bar.py @@ -0,0 +1,81 @@ +"""The status bar — a slim mono strip of live readouts. + +30px, ``panel-2`` ground, monospace. Section / alignment / cursor x,y on the +left; trace count and zoom on the right. The active *values* are teal; their +labels are muted. +""" +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel + +from ..utils import theme + + +class StudioStatusBar(QWidget): + """Mono status strip; set fields via the ``set_*`` methods.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioStatusBar") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setFixedHeight(30) + self._tok = theme.studio_tokens() + self._fields = { + "section": "148", "alignment": "default", + "x": "0.8072", "y": "0.8439", + "traces": "61,121", "zoom": "240%", + } + + lay = QHBoxLayout(self) + lay.setContentsMargins(13, 0, 13, 0) + lay.setSpacing(17) + self._section = self._seg(lay) + self._alignment = self._seg(lay) + self._xy = self._seg(lay) + lay.addStretch(1) + self._traces = self._seg(lay) + self._zoom = self._seg(lay) + self._refresh() + + def _seg(self, layout): + lab = QLabel(self) + lab.setObjectName("studioStatusText") + lab.setTextFormat(Qt.RichText) + layout.addWidget(lab) + return lab + + # --- public API ------------------------------------------------------- + def set_section(self, value): + self._fields["section"] = str(value) + self._refresh() + + def set_alignment(self, value): + self._fields["alignment"] = str(value) + self._refresh() + + def set_position(self, x, y): + self._fields["x"], self._fields["y"] = str(x), str(y) + self._refresh() + + def set_traces(self, value): + self._fields["traces"] = str(value) + self._refresh() + + def set_zoom(self, value): + self._fields["zoom"] = str(value) + self._refresh() + + def apply_theme(self, theme_name=None): + self._tok = theme.studio_tokens(theme_name) + self._refresh() + + # --- internals -------------------------------------------------------- + def _v(self, value): + return f"{value}" + + def _refresh(self): + f = self._fields + self._section.setText(f"section {self._v(f['section'])}") + self._alignment.setText(f"alignment {self._v(f['alignment'])}") + self._xy.setText(f"x {self._v(f['x'])} y {self._v(f['y'])}") + self._traces.setText(f"{self._v(f['traces'])} traces") + self._zoom.setText(f"zoom {self._v(f['zoom'])}") diff --git a/PyReconstruct/modules/gui/studio/title_strip.py b/PyReconstruct/modules/gui/studio/title_strip.py new file mode 100644 index 00000000..671ec7d1 --- /dev/null +++ b/PyReconstruct/modules/gui/studio/title_strip.py @@ -0,0 +1,100 @@ +"""The title strip — brand, menu names, alignment chip, and the ⌘K field. + +40px, ``panel-2`` ground. A conic-gradient brand mark + "Py**Reconstruct**" +(with "Reconstruct" in teal), the familiar menu names, and on the right an +"Alignment · default" chip plus a ⌘K "Search actions" field. + +This is a representative strip for the shell and the sign-off preview — the real +main window keeps its native ``QMenuBar``; the strip exists so the Studio layout +reads as a whole. The ⌘K field's *affordance* is in scope; its full +command-palette behaviour is a follow-up. +""" +from PySide6.QtCore import Qt, QRectF +from PySide6.QtGui import QPainter, QConicalGradient, QColor +from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QFrame + +from ..utils import theme + +_MENU = ["File", "Edit", "Series", "Section", "Lists", + "Alignments", "Autosegment", "View", "Help"] +_DOTS = ("#f0655a", "#f4bd4f", "#3ecf8e") # window controls (decorative) + + +class _BrandMark(QWidget): + """The 18px conic-gradient app mark.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedSize(18, 18) + + def paintEvent(self, _event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + g = QConicalGradient(9, 9, 200) + g.setColorAt(0.00, QColor("#37c0a6")) + g.setColorAt(0.25, QColor("#5ab0f0")) + g.setColorAt(0.50, QColor("#b06cf0")) + g.setColorAt(0.75, QColor("#f4bd4f")) + g.setColorAt(1.00, QColor("#37c0a6")) + p.setPen(Qt.NoPen) + p.setBrush(g) + p.drawRoundedRect(QRectF(0, 0, 18, 18), 5, 5) + p.end() + + +class _Dot(QLabel): + def __init__(self, color, parent=None): + super().__init__(parent) + self.setFixedSize(11, 11) + self.setStyleSheet(f"background:{color}; border-radius:5px;") + + +class StudioTitleStrip(QWidget): + """Brand + menu + alignment chip + ⌘K search field.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioTitleStrip") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setFixedHeight(40) + + lay = QHBoxLayout(self) + lay.setContentsMargins(11, 0, 11, 0) + lay.setSpacing(7) + + for c in _DOTS: + lay.addWidget(_Dot(c, self)) + + lay.addSpacing(6) + lay.addWidget(_BrandMark(self)) + brand = QLabel("Py", self) + brand.setObjectName("studioBrand") + brand_accent = QLabel("Reconstruct", self) + brand_accent.setObjectName("studioBrandAccent") + lay.addWidget(brand) + lay.addSpacing(0) + lay.addWidget(brand_accent) + + lay.addSpacing(10) + for name in _MENU: + item = QLabel(name, self) + item.setObjectName("studioMenuItem") + lay.addWidget(item) + + lay.addStretch(1) + + chip = QLabel("Alignment · default", self) + chip.setObjectName("studioChip") + lay.addWidget(chip) + + cmdk = QFrame(self) + cmdk.setObjectName("studioCmdk") + cmdk.setAttribute(Qt.WA_StyledBackground, True) + cl = QHBoxLayout(cmdk) + cl.setContentsMargins(9, 4, 7, 4) + cl.setSpacing(8) + cl.addWidget(QLabel("Search actions", cmdk)) + kbd = QLabel("⌘K", cmdk) + kbd.setObjectName("studioCmdkKbd") + cl.addWidget(kbd) + lay.addWidget(cmdk) diff --git a/PyReconstruct/modules/gui/studio/tool_rail.py b/PyReconstruct/modules/gui/studio/tool_rail.py new file mode 100644 index 00000000..e9d4e72c --- /dev/null +++ b/PyReconstruct/modules/gui/studio/tool_rail.py @@ -0,0 +1,81 @@ +"""The tool rail — the flush right strip of drawing tools. + +60px wide, ``panel-2`` ground, a hairline border-left, and 38x38 tiles with +muted icons. Deliberately **flush-docked**: not a floating card — no blur, no +shadow, no "TOOLS" header, no keycap badges, no glow. The active tool reads as +"lit from within" (a subtle gradient tile + teal icon + teal border), nothing +more. A small gap separates the drawing tools from Flag / Host. +""" +from PySide6.QtCore import Signal, Qt +from PySide6.QtWidgets import QWidget, QVBoxLayout + +from ._common import IconTileButton + +#: (key, tooltip, icon_svg_name); ``None`` is the fixed gap between groups +TOOL_ITEMS = [ + ("pointer", "Pointer", "pointer"), + ("panzoom", "Pan / zoom", "panzoom"), + ("closedtrace", "Closed trace", "closedtrace"), + ("opentrace", "Open trace", "opentrace"), + ("knife", "Knife", "knife"), + ("stamp", "Stamp", "stamp"), + ("grid", "Grid", "grid"), + None, + ("flag", "Flag", "flag"), + ("host", "Host", "host"), +] + +_TILE_PX = 38 +_ICON_PX = 22 +_GAP_PX = 7 + + +class ToolRail(QWidget): + """Vertical tool rail; emits :attr:`tool_selected` with the active tool key.""" + + tool_selected = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioToolRail") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setFixedWidth(60) + self._buttons = {} + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 8, 0, 8) + layout.setSpacing(5) + layout.setAlignment(Qt.AlignHCenter | Qt.AlignTop) + + for item in TOOL_ITEMS: + if item is None: + layout.addSpacing(_GAP_PX) + continue + key, tip, icon_name = item + btn = IconTileButton(key, icon_name, "studioToolButton", _TILE_PX, _ICON_PX, self) + btn.setToolTip(tip) + btn.clicked.connect(lambda _=False, k=key: self._on_click(k)) + layout.addWidget(btn, 0, Qt.AlignHCenter) + self._buttons[key] = btn + + layout.addStretch(1) + self.set_active("pointer") + + def _on_click(self, key): + self.set_active(key) + self.tool_selected.emit(key) + + def set_active(self, key): + """Light the tile for ``key`` (no-op if unknown); clears the others.""" + if key not in self._buttons: + return + for k, btn in self._buttons.items(): + btn.set_active(k == key) + self._active_key = key + + def active_key(self): + return getattr(self, "_active_key", None) + + def retint(self, rest_color, active_color): + for btn in self._buttons.values(): + btn.retint(rest_color, active_color) diff --git a/dev/studio_preview.py b/dev/studio_preview.py new file mode 100644 index 00000000..39ee676a --- /dev/null +++ b/dev/studio_preview.py @@ -0,0 +1,73 @@ +"""Offscreen (or on-screen) preview of the Studio layout. + +Builds the real :class:`StudioShell` populated with the mockup's content and +renders it in both shells — Studio (dark) and Atlas (light) — for sign-off. + +Run it against the worktree (it imports the package, so set PYTHONPATH): + + PYTHONPATH= QT_QPA_PLATFORM=offscreen \\ + python dev/studio_preview.py --out # write studio.png + atlas.png + + PYTHONPATH= python dev/studio_preview.py --show studio # interactive + +The Studio widgets are a pure view layer, so this needs only PySide6 + +qdarkstyle from the runtime — none of the heavy field/series deps. +""" +import argparse +import os +import sys + +from PySide6.QtWidgets import QApplication + +from PyReconstruct.modules.gui.studio.shell import StudioShell + +WIDTH, HEIGHT = 1200, 604 +THEMES = ("studio", "atlas") + + +def render(out_dir, width=WIDTH, height=HEIGHT): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + app = QApplication.instance() or QApplication(sys.argv) + os.makedirs(out_dir, exist_ok=True) + paths = {} + shell = StudioShell.demo() + shell.resize(width, height) + shell.show() + for name in THEMES: + shell.apply_theme(name, app_wide=True) + app.processEvents() + app.processEvents() + pm = shell.grab() + path = os.path.join(out_dir, f"{name}.png") + pm.save(path, "PNG") + paths[name] = path + print(f"{name}: {path} ({pm.width()}x{pm.height()})") + return paths + + +def show(theme_name): + app = QApplication.instance() or QApplication(sys.argv) + shell = StudioShell.demo() + shell.resize(WIDTH, HEIGHT) + shell.apply_theme(theme_name, app_wide=True) + shell.setWindowTitle(f"PyReconstruct — Studio layout preview ({theme_name})") + shell.show() + sys.exit(app.exec()) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", default=None, help="directory to write studio.png + atlas.png") + ap.add_argument("--show", choices=THEMES, help="show a theme interactively") + ap.add_argument("--width", type=int, default=WIDTH) + ap.add_argument("--height", type=int, default=HEIGHT) + args = ap.parse_args() + if args.show: + show(args.show) + else: + out = args.out or os.path.join(os.path.dirname(__file__), "..", "previews", "studio") + render(os.path.abspath(out), args.width, args.height) + + +if __name__ == "__main__": + main() diff --git a/tests/test_studio_layout.py b/tests/test_studio_layout.py new file mode 100644 index 00000000..cc1e9a13 --- /dev/null +++ b/tests/test_studio_layout.py @@ -0,0 +1,270 @@ +"""Tests for the Studio layout widgets (gui.studio). + +These pin the foundation's structure and the spec's non-negotiables that can be +checked headlessly: the chrome stylesheet builds for both shells and never +carries azure; the activity rail and tool rail switch active state and signal; +the Objects panel renders a virtualizing model/view, filters, and reports +selection; the canvas hosts the glassy floats and steps sections; the palette +strip and status bar behave; and the whole shell assembles, toggles Studio ↔ +Atlas, and grabs to a non-null pixmap with no azure in its applied chrome. + +Qt-backed; skipped if PySide6/qdarkstyle are unavailable. +""" +import os +import pytest + +pytest.importorskip("PySide6") +pytest.importorskip("PySide6.QtSvg") +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication, QWidget # noqa: E402 + +from PyReconstruct.modules.gui.studio import qss # noqa: E402 +from PyReconstruct.modules.gui.studio.rail import ActivityRail, RAIL_ITEMS # noqa: E402 +from PyReconstruct.modules.gui.studio.tool_rail import ToolRail, TOOL_ITEMS # noqa: E402 +from PyReconstruct.modules.gui.studio.objects_panel import ObjectsPanel # noqa: E402 +from PyReconstruct.modules.gui.studio.canvas import CanvasArea # noqa: E402 +from PyReconstruct.modules.gui.studio.palette_strip import PaletteStrip # noqa: E402 +from PyReconstruct.modules.gui.studio.status_bar import StudioStatusBar # noqa: E402 +from PyReconstruct.modules.gui.studio.shell import StudioShell # noqa: E402 + +AZURE_HUES = ("#4c8dff", "#3d8bd4") + + +@pytest.fixture(scope="module") +def qapp(): + app = QApplication.instance() or QApplication([]) + yield app + + +def _collect(signal): + """Connect a signal to a list and return it for later assertions.""" + seen = [] + signal.connect(lambda *a: seen.append(a[0] if len(a) == 1 else a)) + return seen + + +# --- chrome stylesheet ------------------------------------------------------- +@pytest.mark.parametrize("theme_name", ["studio", "atlas"]) +def test_chrome_qss_builds_and_has_no_azure(qapp, theme_name): + css = qss.chrome_qss(theme_name) + assert css and "#studioActivityRail" in css and "#studioToolRail" in css + assert "#studioObjectsPanel" in css and "#studioStatusBar" in css + low = css.lower() + for azure in AZURE_HUES: + assert azure not in low, f"azure {azure} in {theme_name} chrome" + + +def test_chrome_qss_differs_between_shells(qapp): + # same selectors, different neutral shell (Studio dark vs Atlas light) + assert qss.chrome_qss("studio") != qss.chrome_qss("atlas") + assert "#0c0f14" in qss.chrome_qss("studio") # dark ground + assert "#f4f6fa" in qss.chrome_qss("atlas") # light ground + # the teal accent is in both + assert "#37c0a6" in qss.chrome_qss("studio") and "#37c0a6" in qss.chrome_qss("atlas") + + +# --- activity rail ----------------------------------------------------------- +def test_activity_rail_switches_and_signals(qapp): + rail = ActivityRail() + keys = [it[0] for it in RAIL_ITEMS if it] + assert {"objects", "traces", "sections", "flags", "scene3d", "settings"} <= set(keys) + assert rail.active_key() == "objects" # default + fired = _collect(rail.activated) + rail._on_click("traces") + assert rail.active_key() == "traces" + assert rail._buttons["traces"].is_active() + assert not rail._buttons["objects"].is_active() + assert fired == ["traces"] + rail.retint("#6a7686", "#37c0a6") # no raise + + +# --- tool rail --------------------------------------------------------------- +def test_tool_rail_flush_switches_and_signals(qapp): + tr = ToolRail() + keys = [it[0] for it in TOOL_ITEMS if it] + assert {"pointer", "panzoom", "closedtrace", "opentrace", "knife", + "stamp", "grid", "flag", "host"} <= set(keys) + assert tr.active_key() == "pointer" + assert tr.width() == 60 + fired = _collect(tr.tool_selected) + tr._on_click("knife") + assert tr.active_key() == "knife" and fired == ["knife"] + assert tr._buttons["knife"].is_active() + + +# --- objects panel ----------------------------------------------------------- +def _objs(n): + return [{"name": f"obj_{i}", "type": "dendrite" if i % 2 else "", + "color": "#37c0a6", "status": ("curated", "review", "flagged")[i % 3]} + for i in range(n)] + + +def test_objects_panel_populates_filters_and_selects(qapp): + panel = ObjectsPanel() + panel.set_objects(_objs(50)) + assert panel._proxy.rowCount() == 50 + assert panel._count.text() == "50" + # selection fires object_activated; first row auto-selected on set + fired = _collect(panel.object_activated) + panel.view.setCurrentIndex(panel._proxy.index(3, 0)) + assert fired and fired[-1] == "obj_3" + # live filter narrows the proxy and updates the count + panel.filter.setText("obj_1") # obj_1, obj_10..obj_19 + assert 0 < panel._proxy.rowCount() < 50 + assert panel._count.text() == str(panel._proxy.rowCount()) + panel.filter.setText("") + assert panel._proxy.rowCount() == 50 + + +def test_objects_panel_retitle_and_theme(qapp): + panel = ObjectsPanel() + panel.set_title("traces") + assert panel._title.text() == "TRACES" + panel.apply_theme("atlas") # re-tints delegate/legend without error + panel.apply_theme("studio") + + +def test_objects_panel_virtualizes_large_list(qapp): + # a large model must not create per-row widgets (model/view + delegate) + panel = ObjectsPanel() + panel.set_objects(_objs(4000)) + assert panel._proxy.rowCount() == 4000 + assert panel._count.text() == "4,000" # mono count, comma-grouped + + +# --- canvas + floats --------------------------------------------------------- +def test_canvas_floats_and_section_step(qapp): + canvas = CanvasArea() + # all four glassy floats exist + for f in (canvas.badge, canvas.scalebar, canvas.secnav, canvas.bc): + assert f.parent() is canvas + stepped = _collect(canvas.section_changed) + canvas.secnav.stepped.emit(1) + assert stepped == [1] + canvas.set_section(149, 287) + canvas.set_active_object("d001", "curated") + canvas.set_brightness(70) + canvas.set_contrast(30) + assert canvas.bc.bright.value() == 70 and canvas.bc.contr.value() == 30 + canvas.apply_theme("atlas") + + +def test_canvas_widget_seam(qapp): + canvas = CanvasArea() + real = QWidget() + canvas.set_canvas_widget(real) + assert real.parent() is canvas + # floats still present and on top + assert canvas.badge.parent() is canvas + + +def test_canvas_bc_signals(qapp): + canvas = CanvasArea() + b = _collect(canvas.brightness_changed) + canvas.bc.bright.setValue(80) + assert b == [80] + + +# --- palette strip ----------------------------------------------------------- +def test_palette_strip_select_and_add(qapp): + strip = PaletteStrip() + strip.set_colors(["#6ce0c4", "#f0a6d8", "#9bd35a", "#37c0a6"]) + strip.set_active(0) + assert len(strip._swatches) == 4 + assert strip._swatches[0]._active and not strip._swatches[1]._active + picked = _collect(strip.color_selected) + strip._on_click(2) + assert picked == [2] and strip._swatches[2]._active + added = _collect(strip.add_requested) + strip._add.click() + assert added # add_requested fired + strip.set_readout("circle · r=0.045") + assert "0.045" in strip._readout.text() + + +# --- status bar -------------------------------------------------------------- +def test_status_bar_values_are_teal(qapp): + sb = StudioStatusBar() + sb.set_section(148) + sb.set_zoom("240%") + # active values carry the teal accent; labels do not + assert "#37c0a6" in sb._section.text() and "148" in sb._section.text() + assert "section" in sb._section.text() + assert "240%" in sb._zoom.text() + + +# --- the whole shell --------------------------------------------------------- +def test_shell_demo_assembles(qapp): + shell = StudioShell.demo() + # every region is present + for region in (shell.title_strip, shell.rail, shell.objects_panel, + shell.canvas, shell.tool_rail, shell.palette_strip, + shell.status_bar): + assert region is not None and region.parent() is not None + # the demo object list reads like the mockup count and virtualizes + assert shell.objects_panel._proxy.rowCount() == 3809 + assert shell.objects_panel._count.text() == "3,809" + assert shell.rail.active_key() == "objects" + assert shell.tool_rail.active_key() == "pointer" + + +def test_shell_rail_repoints_panel(qapp): + shell = StudioShell.demo() + shell._on_rail("traces") + assert shell.objects_panel._title.text() == "TRACES" + assert 0 < shell.objects_panel._proxy.rowCount() < 3809 + shell._on_rail("objects") + assert shell.objects_panel._proxy.rowCount() == 3809 + + +def test_shell_seam_items_light_but_dont_repoint(qapp): + # 3D / Settings are honest seams: clicking lights the rail + emits, but the + # left panel must NOT repoint (no fake feature behind them). + shell = StudioShell.demo() + title_before = shell.objects_panel._title.text() + rows_before = shell.objects_panel._proxy.rowCount() + fired = _collect(shell.rail.activated) + for seam in ("scene3d", "settings"): + shell.rail._on_click(seam) + assert shell.rail.active_key() == seam + assert seam in fired + assert shell.objects_panel._title.text() == title_before + assert shell.objects_panel._proxy.rowCount() == rows_before + + +def test_shell_object_selection_updates_badge(qapp): + shell = StudioShell.demo() + shell._on_object("psd_018") # a flagged object + assert "psd_018" in shell.canvas.badge._text.text() + assert shell.canvas.badge._status == "flagged" + + +def test_shell_section_step(qapp): + shell = StudioShell.demo() + shell._on_section_step(1) + assert shell._section_idx == 149 + assert "149" in shell.status_bar._section.text() + # clamps at the bottom + shell._section_idx = 1 + shell._on_section_step(-1) + assert shell._section_idx == 1 + + +@pytest.mark.parametrize("theme_name", ["studio", "atlas"]) +def test_shell_theme_toggle_no_azure(qapp, theme_name): + shell = StudioShell.demo() + shell.apply_theme(theme_name, app_wide=False) + low = shell.styleSheet().lower() + assert low, "chrome stylesheet must be applied to the shell" + for azure in AZURE_HUES: + assert azure not in low, f"azure {azure} in shell chrome for {theme_name}" + + +def test_shell_renders_offscreen(qapp): + shell = StudioShell.demo() + shell.resize(1180, 560) + shell.show() + qapp.processEvents() + pm = shell.grab() + assert not pm.isNull() and pm.width() >= 1180 From d38260438448d87b9cbc23ee341e1859dfe77a93 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Mon, 29 Jun 2026 06:48:44 -0500 Subject: [PATCH 4/6] feat(ui): offer Studio and Atlas in the theme picker Add Studio (dark) and Atlas (light) to the theme radio in both the View-menu theme dialog and the options dialog, persisted through the existing "theme" setting. This makes the new token layer selectable in the running app while the bespoke Studio layout widgets are adopted into the main window separately. --- .../modules/gui/dialog/all_options.py | 4 ++++ PyReconstruct/modules/gui/main/main_window.py | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/PyReconstruct/modules/gui/dialog/all_options.py b/PyReconstruct/modules/gui/dialog/all_options.py index e93e61d3..c73d91c0 100644 --- a/PyReconstruct/modules/gui/dialog/all_options.py +++ b/PyReconstruct/modules/gui/dialog/all_options.py @@ -257,6 +257,8 @@ def setOption(response): ("System", mode == "system"), ("Light", mode == "light"), ("Dark", mode == "dark"), + ("Studio (dark)", mode == "studio"), + ("Atlas (light)", mode == "atlas"), )], ] @@ -265,6 +267,8 @@ def setOption(response): if response[0][0][1]: m = "system" elif response[0][1][1]: m = "light" elif response[0][2][1]: m = "dark" + elif response[0][3][1]: m = "studio" + elif response[0][4][1]: m = "atlas" else: m = "system" self.series.setOption("theme", m) diff --git a/PyReconstruct/modules/gui/main/main_window.py b/PyReconstruct/modules/gui/main/main_window.py index 91eb2dca..164215b3 100644 --- a/PyReconstruct/modules/gui/main/main_window.py +++ b/PyReconstruct/modules/gui/main/main_window.py @@ -3011,10 +3011,12 @@ def load3DScene(self): def setTheme(self, new_theme=None): """Set the UI theme. - Modes: "system" (follow the OS light/dark scheme), "light", "dark". - 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.""" + Modes: "system" (follow the OS light/dark scheme), "light", "dark", and + the named "studio" (dark) / "atlas" (light) concept themes (the v1.30 + UI overhaul's teal palette). 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.""" if new_theme is None: mode = theme.normalize_mode(self.series.getOption("theme")) structure = [ @@ -3022,7 +3024,9 @@ def setTheme(self, new_theme=None): [("radio", ("System", mode == "system"), ("Light", mode == "light"), - ("Dark", mode == "dark"))] + ("Dark", mode == "dark"), + ("Studio (dark)", mode == "studio"), + ("Atlas (light)", mode == "atlas"))] ] response, confirmed = QuickDialog.get( self, structure, "Theme" @@ -3036,6 +3040,10 @@ def setTheme(self, new_theme=None): new_theme = "light" elif response[0][2][1]: new_theme = "dark" + elif response[0][3][1]: + new_theme = "studio" + elif response[0][4][1]: + new_theme = "atlas" else: return From 51d387f73a07b7e8bdbc60f611ebde52de49cdce Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Tue, 30 Jun 2026 19:28:01 -0500 Subject: [PATCH 5/6] feat(ui): use the fork app icon as the brand mark and window icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the title strip's placeholder conic-gradient mark with the shipped PyReconstruct app icon — the dark-glass squircle on the Studio shell, the light squircle on Atlas, mirroring how the main window chooses the OS window icon — and set it as the shell's window icon so the real window decoration carries it too. Falls back to the conic mark if the asset can't be loaded. The icon path is resolved relative to the package, so the view layer gains no new dependency. --- PyReconstruct/modules/gui/studio/_common.py | 16 ++++++ PyReconstruct/modules/gui/studio/shell.py | 7 +++ .../modules/gui/studio/title_strip.py | 51 ++++++++++++++----- tests/test_studio_layout.py | 21 ++++++++ 4 files changed, 81 insertions(+), 14 deletions(-) diff --git a/PyReconstruct/modules/gui/studio/_common.py b/PyReconstruct/modules/gui/studio/_common.py index 57ccf4a1..e8c60108 100644 --- a/PyReconstruct/modules/gui/studio/_common.py +++ b/PyReconstruct/modules/gui/studio/_common.py @@ -1,9 +1,25 @@ """Small shared helpers for the Studio widgets.""" +import os + from PySide6.QtCore import QSize from PySide6.QtWidgets import QToolButton from ..utils import icons +# The shipped app icon (the fork's low-poly Python mark on a squircle), resolved +# relative to the package so the view layer needs no new cross-module import and +# still works headless / offscreen. Dark and light squircles match the two +# shells (as gui.main uses for the OS window icon). +_ASSET_IMG = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "assets", "img")) +_APP_ICON = {"dark": "PyReconstruct.png", "light": "PyReconstruct-light.png"} + + +def app_icon_path(family="dark"): + """Filesystem path to the app icon for a color family, or None if missing.""" + path = os.path.join(_ASSET_IMG, _APP_ICON.get(family, _APP_ICON["dark"])) + return path if os.path.exists(path) else None + def repolish(widget): """Re-evaluate a widget's QSS after a dynamic property changed. diff --git a/PyReconstruct/modules/gui/studio/shell.py b/PyReconstruct/modules/gui/studio/shell.py index 8e148a61..ac31c881 100644 --- a/PyReconstruct/modules/gui/studio/shell.py +++ b/PyReconstruct/modules/gui/studio/shell.py @@ -19,10 +19,12 @@ content (including a 3,809-row object list, to prove the list virtualizes). """ from PySide6.QtCore import Qt +from PySide6.QtGui import QIcon from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QApplication from ..utils import theme from . import qss +from ._common import app_icon_path from .title_strip import StudioTitleStrip from .rail import ActivityRail from .objects_panel import ObjectsPanel @@ -39,6 +41,7 @@ def __init__(self, parent=None): super().__init__(parent) self.setObjectName("studioShell") self.setAttribute(Qt.WA_StyledBackground, True) + self.setWindowTitle("PyReconstruct") self._theme = None self._section_idx, self._section_total = 148, 287 self._datasets = {} @@ -144,6 +147,10 @@ def apply_theme(self, theme_name=None, app_wide=True): self.canvas.apply_theme(theme_name) self.palette_strip.apply_theme(theme_name) self.status_bar.apply_theme(theme_name) + self.title_strip.apply_theme(theme_name) + icon_path = app_icon_path(tok["family"]) + if icon_path: + self.setWindowIcon(QIcon(icon_path)) def current_theme(self): return self._theme diff --git a/PyReconstruct/modules/gui/studio/title_strip.py b/PyReconstruct/modules/gui/studio/title_strip.py index 671ec7d1..5675184e 100644 --- a/PyReconstruct/modules/gui/studio/title_strip.py +++ b/PyReconstruct/modules/gui/studio/title_strip.py @@ -10,10 +10,11 @@ command-palette behaviour is a follow-up. """ from PySide6.QtCore import Qt, QRectF -from PySide6.QtGui import QPainter, QConicalGradient, QColor +from PySide6.QtGui import QPainter, QConicalGradient, QColor, QPixmap from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QFrame from ..utils import theme +from ._common import app_icon_path _MENU = ["File", "Edit", "Series", "Section", "Lists", "Alignments", "Autosegment", "View", "Help"] @@ -21,24 +22,41 @@ class _BrandMark(QWidget): - """The 18px conic-gradient app mark.""" + """The app mark — the real fork icon (per shell family). - def __init__(self, parent=None): + Uses the shipped squircle icon (dark on the Studio shell, light on Atlas, + mirroring the OS window icon). Falls back to the conic-gradient placeholder + if the asset can't be loaded, so the strip never renders empty. + """ + + def __init__(self, size=18, parent=None): super().__init__(parent) - self.setFixedSize(18, 18) + self._size = size + self.setFixedSize(size, size) + self._pixmap = None + self.set_family("dark") + + def set_family(self, family): + path = app_icon_path(family) + pm = QPixmap(path) if path else QPixmap() + self._pixmap = pm if not pm.isNull() else None + self.update() def paintEvent(self, _event): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing, True) - g = QConicalGradient(9, 9, 200) - g.setColorAt(0.00, QColor("#37c0a6")) - g.setColorAt(0.25, QColor("#5ab0f0")) - g.setColorAt(0.50, QColor("#b06cf0")) - g.setColorAt(0.75, QColor("#f4bd4f")) - g.setColorAt(1.00, QColor("#37c0a6")) - p.setPen(Qt.NoPen) - p.setBrush(g) - p.drawRoundedRect(QRectF(0, 0, 18, 18), 5, 5) + p.setRenderHint(QPainter.SmoothPixmapTransform, True) + r = self.rect() + if self._pixmap is not None: + p.drawPixmap(r, self._pixmap) + else: + g = QConicalGradient(r.center().x(), r.center().y(), 200) + for pos, col in ((0.0, "#37c0a6"), (0.25, "#5ab0f0"), (0.5, "#b06cf0"), + (0.75, "#f4bd4f"), (1.0, "#37c0a6")): + g.setColorAt(pos, QColor(col)) + p.setPen(Qt.NoPen) + p.setBrush(g) + p.drawRoundedRect(QRectF(r), 5, 5) p.end() @@ -66,7 +84,8 @@ def __init__(self, parent=None): lay.addWidget(_Dot(c, self)) lay.addSpacing(6) - lay.addWidget(_BrandMark(self)) + self._brand = _BrandMark(18, self) + lay.addWidget(self._brand) brand = QLabel("Py", self) brand.setObjectName("studioBrand") brand_accent = QLabel("Reconstruct", self) @@ -98,3 +117,7 @@ def __init__(self, parent=None): kbd.setObjectName("studioCmdkKbd") cl.addWidget(kbd) lay.addWidget(cmdk) + + def apply_theme(self, theme_name=None): + """Match the brand mark to the shell family (dark/light squircle).""" + self._brand.set_family(theme.studio_tokens(theme_name)["family"]) diff --git a/tests/test_studio_layout.py b/tests/test_studio_layout.py index cc1e9a13..51f4f6f4 100644 --- a/tests/test_studio_layout.py +++ b/tests/test_studio_layout.py @@ -261,6 +261,27 @@ def test_shell_theme_toggle_no_azure(qapp, theme_name): assert azure not in low, f"azure {azure} in shell chrome for {theme_name}" +def test_title_strip_brand_uses_app_icon(qapp): + from PyReconstruct.modules.gui.studio._common import app_icon_path + from PyReconstruct.modules.gui.studio.title_strip import StudioTitleStrip + ts = StudioTitleStrip() + ts.apply_theme("studio") # dark squircle + ts.apply_theme("atlas") # light squircle — neither path errors + # the shipped fork icons resolve in this checkout + assert app_icon_path("dark") and app_icon_path("light") + assert app_icon_path("dark").endswith("PyReconstruct.png") + assert app_icon_path("light").endswith("PyReconstruct-light.png") + + +def test_shell_sets_window_icon(qapp): + from PyReconstruct.modules.gui.studio._common import app_icon_path + shell = StudioShell.demo() + for th in ("studio", "atlas"): + shell.apply_theme(th, app_wide=False) + if app_icon_path("dark"): # assets present -> window icon is set + assert not shell.windowIcon().isNull() + + def test_shell_renders_offscreen(qapp): shell = StudioShell.demo() shell.resize(1180, 560) From 7ede9615a9adfe18c81bfd0dda32088325ef4d90 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Tue, 30 Jun 2026 19:50:39 -0500 Subject: [PATCH 6/6] fix(ui): use the bare fork logo as the brand mark and close the wordmark gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the title strip's squircle app icon for the bare transparent fork logo (logo.png), which reads on both the dark and light title bars, and use it as the window icon too. Collapse the "Py" / "Reconstruct" wordmark into a single rich-text label so they render as one word — previously the layout's item spacing (plus a stray zero-width spacer) opened a visible gap between them. "Reconstruct" keeps the teal accent, re-tinted with the theme. --- PyReconstruct/modules/gui/studio/_common.py | 11 ++++- PyReconstruct/modules/gui/studio/qss.py | 1 - PyReconstruct/modules/gui/studio/shell.py | 8 ++-- .../modules/gui/studio/title_strip.py | 46 +++++++++---------- tests/test_studio_layout.py | 26 +++++------ 5 files changed, 48 insertions(+), 44 deletions(-) diff --git a/PyReconstruct/modules/gui/studio/_common.py b/PyReconstruct/modules/gui/studio/_common.py index e8c60108..31d87f2d 100644 --- a/PyReconstruct/modules/gui/studio/_common.py +++ b/PyReconstruct/modules/gui/studio/_common.py @@ -16,11 +16,20 @@ def app_icon_path(family="dark"): - """Filesystem path to the app icon for a color family, or None if missing.""" + """Filesystem path to the squircle app icon for a color family, or None.""" path = os.path.join(_ASSET_IMG, _APP_ICON.get(family, _APP_ICON["dark"])) return path if os.path.exists(path) else None +def logo_path(): + """Filesystem path to the bare (transparent) fork logo, or None if missing. + + The flat mark — no squircle — reads on both the dark and light title bars. + """ + path = os.path.join(_ASSET_IMG, "logo.png") + return path if os.path.exists(path) else None + + def repolish(widget): """Re-evaluate a widget's QSS after a dynamic property changed. diff --git a/PyReconstruct/modules/gui/studio/qss.py b/PyReconstruct/modules/gui/studio/qss.py index bba9f651..89c07130 100644 --- a/PyReconstruct/modules/gui/studio/qss.py +++ b/PyReconstruct/modules/gui/studio/qss.py @@ -36,7 +36,6 @@ border-bottom: 1px solid ${line}; } #studioBrand { color: ${ink}; font-weight: 800; font-size: 14px; } -#studioBrandAccent { color: ${accent}; font-weight: 800; font-size: 14px; } QLabel#studioMenuItem { color: ${muted}; font-size: 12px; padding: 3px 7px; border-radius: 6px; } QLabel#studioMenuItem:hover { color: ${ink}; background: ${raised}; } #studioChip { diff --git a/PyReconstruct/modules/gui/studio/shell.py b/PyReconstruct/modules/gui/studio/shell.py index ac31c881..c65a8ad4 100644 --- a/PyReconstruct/modules/gui/studio/shell.py +++ b/PyReconstruct/modules/gui/studio/shell.py @@ -24,7 +24,7 @@ from ..utils import theme from . import qss -from ._common import app_icon_path +from ._common import logo_path from .title_strip import StudioTitleStrip from .rail import ActivityRail from .objects_panel import ObjectsPanel @@ -42,6 +42,9 @@ def __init__(self, parent=None): self.setObjectName("studioShell") self.setAttribute(Qt.WA_StyledBackground, True) self.setWindowTitle("PyReconstruct") + _logo = logo_path() + if _logo: + self.setWindowIcon(QIcon(_logo)) self._theme = None self._section_idx, self._section_total = 148, 287 self._datasets = {} @@ -148,9 +151,6 @@ def apply_theme(self, theme_name=None, app_wide=True): self.palette_strip.apply_theme(theme_name) self.status_bar.apply_theme(theme_name) self.title_strip.apply_theme(theme_name) - icon_path = app_icon_path(tok["family"]) - if icon_path: - self.setWindowIcon(QIcon(icon_path)) def current_theme(self): return self._theme diff --git a/PyReconstruct/modules/gui/studio/title_strip.py b/PyReconstruct/modules/gui/studio/title_strip.py index 5675184e..3c3a9c43 100644 --- a/PyReconstruct/modules/gui/studio/title_strip.py +++ b/PyReconstruct/modules/gui/studio/title_strip.py @@ -14,7 +14,7 @@ from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QFrame from ..utils import theme -from ._common import app_icon_path +from ._common import logo_path _MENU = ["File", "Edit", "Series", "Section", "Lists", "Alignments", "Autosegment", "View", "Help"] @@ -22,25 +22,20 @@ class _BrandMark(QWidget): - """The app mark — the real fork icon (per shell family). + """The app mark — the bare (transparent) fork logo. - Uses the shipped squircle icon (dark on the Studio shell, light on Atlas, - mirroring the OS window icon). Falls back to the conic-gradient placeholder - if the asset can't be loaded, so the strip never renders empty. + The flat mark reads on both the dark and light title bars, so it needs no + per-theme variant. Falls back to the conic-gradient placeholder if the asset + can't be loaded, so the strip never renders empty. """ - def __init__(self, size=18, parent=None): + def __init__(self, size=22, parent=None): super().__init__(parent) self._size = size self.setFixedSize(size, size) - self._pixmap = None - self.set_family("dark") - - def set_family(self, family): - path = app_icon_path(family) + path = logo_path() pm = QPixmap(path) if path else QPixmap() self._pixmap = pm if not pm.isNull() else None - self.update() def paintEvent(self, _event): p = QPainter(self) @@ -83,18 +78,18 @@ def __init__(self, parent=None): for c in _DOTS: lay.addWidget(_Dot(c, self)) - lay.addSpacing(6) - self._brand = _BrandMark(18, self) + lay.addSpacing(4) + self._brand = _BrandMark(22, self) lay.addWidget(self._brand) - brand = QLabel("Py", self) - brand.setObjectName("studioBrand") - brand_accent = QLabel("Reconstruct", self) - brand_accent.setObjectName("studioBrandAccent") - lay.addWidget(brand) - lay.addSpacing(0) - lay.addWidget(brand_accent) - - lay.addSpacing(10) + # one label for the whole wordmark so "Py" and "Reconstruct" are a single + # word (no layout gap between them); "Reconstruct" takes the teal accent. + self._brand_label = QLabel(self) + self._brand_label.setObjectName("studioBrand") + self._brand_label.setTextFormat(Qt.RichText) + self._brand_label.setText('PyReconstruct') + lay.addWidget(self._brand_label) + + lay.addSpacing(12) for name in _MENU: item = QLabel(name, self) item.setObjectName("studioMenuItem") @@ -119,5 +114,6 @@ def __init__(self, parent=None): lay.addWidget(cmdk) def apply_theme(self, theme_name=None): - """Match the brand mark to the shell family (dark/light squircle).""" - self._brand.set_family(theme.studio_tokens(theme_name)["family"]) + """Re-tint the wordmark's accent to the active theme (mark is theme-free).""" + accent = theme.studio_tokens(theme_name)["accent"] + self._brand_label.setText(f'PyReconstruct') diff --git a/tests/test_studio_layout.py b/tests/test_studio_layout.py index 51f4f6f4..b2a10242 100644 --- a/tests/test_studio_layout.py +++ b/tests/test_studio_layout.py @@ -261,25 +261,25 @@ def test_shell_theme_toggle_no_azure(qapp, theme_name): assert azure not in low, f"azure {azure} in shell chrome for {theme_name}" -def test_title_strip_brand_uses_app_icon(qapp): - from PyReconstruct.modules.gui.studio._common import app_icon_path +def test_title_strip_brand_uses_bare_logo_and_single_wordmark(qapp): + from PyReconstruct.modules.gui.studio._common import logo_path from PyReconstruct.modules.gui.studio.title_strip import StudioTitleStrip ts = StudioTitleStrip() - ts.apply_theme("studio") # dark squircle - ts.apply_theme("atlas") # light squircle — neither path errors - # the shipped fork icons resolve in this checkout - assert app_icon_path("dark") and app_icon_path("light") - assert app_icon_path("dark").endswith("PyReconstruct.png") - assert app_icon_path("light").endswith("PyReconstruct-light.png") + ts.apply_theme("studio") + ts.apply_theme("atlas") # neither errors; the mark is theme-free + assert logo_path() and logo_path().endswith("logo.png") + # the wordmark is one label (no stray "Py | Reconstruct" gap), teal accent on + # "Reconstruct" + text = ts._brand_label.text() + assert text.startswith("Py") and "Reconstruct" in text + assert "#37c0a6" in text def test_shell_sets_window_icon(qapp): - from PyReconstruct.modules.gui.studio._common import app_icon_path + from PyReconstruct.modules.gui.studio._common import logo_path shell = StudioShell.demo() - for th in ("studio", "atlas"): - shell.apply_theme(th, app_wide=False) - if app_icon_path("dark"): # assets present -> window icon is set - assert not shell.windowIcon().isNull() + if logo_path(): # asset present -> window icon is set (theme-independent) + assert not shell.windowIcon().isNull() def test_shell_renders_offscreen(qapp):