Source code for simvx.core.ui.theme

"""Shared application theme -- single source of truth for colours and layout.

``AppTheme`` extends the base ``Theme`` with named attributes covering
backgrounds, text, accents, semantic colours, buttons, editor viewports,
gizmos, IDE minimap, autocomplete, and scrollbar styling.

``SyntaxTheme`` provides syntax highlighting colours for code editors.

Variants are driven by module-level palette dicts and ``StyleBoxConfig``
strategies. Factory classmethods (``dark()``, ``light()``, ``monokai()``,
``abyss()``, ``midnight()``, ``solarised_dark()``, ``nord()``) return
pre-configured instances. Module-level ``get_theme()`` / ``set_theme()``
manage a runtime-swappable singleton.

This module is the public facade: the data types (``StyleBox``,
``SyntaxTheme``, ``StyleBoxConfig``) live in the private ``_theme_types``
leaf and the palette builders / per-variant configs in ``_theme_palettes``;
both are re-exported / consumed here so consumers only import from
``simvx.core.ui.theme`` (or ``simvx.core.ui``).

Usage::

    from simvx.core.ui.theme import AppTheme, get_theme, set_theme

    theme = get_theme()           # module-level singleton (dark by default)
    bg = theme.bg                 # direct attribute access
    set_theme(AppTheme.monokai()) # runtime theme switch
"""

from __future__ import annotations

import logging
from typing import Any

from ._theme_palettes import (
    _ABYSS_PALETTE,
    _ABYSS_STYLEBOX,
    _DARK_PALETTE,
    _DARK_STYLEBOX,
    _DEFAULT_SIZES,
    _LIGHT_PALETTE,
    _LIGHT_STYLEBOX,
    _MIDNIGHT_PALETTE,
    _MIDNIGHT_STYLEBOX,
    _MONOKAI_PALETTE,
    _MONOKAI_STYLEBOX,
    _NORD_PALETTE,
    _NORD_STYLEBOX,
    _SOLARISED_DARK_PALETTE,
    _SOLARISED_DARK_STYLEBOX,
)
from ._theme_types import Colour4, StyleBox, StyleBoxConfig, SyntaxTheme
from .types import Theme

log = logging.getLogger(__name__)

__all__ = [
    "StyleBox",
    "SyntaxTheme",
    "StyleBoxConfig",
    "AppTheme",
    "get_theme",
    "set_theme",
    "theme_generation",
    "em",
]


# ---------------------------------------------------------------------------
# AppTheme
# ---------------------------------------------------------------------------

[docs] class AppTheme(Theme): """Full application theme with named colour and layout attributes. Subclasses :class:`Theme` so ``Control.get_theme()`` still works. All attributes are also written into ``self.colours`` / ``self.sizes`` so the dict-based ``get_colour()`` / ``get_size()`` API stays valid. """ # Class-level annotations so static type checkers see the palette-driven attributes. # The values themselves are populated in __init__ from the active palette. bg_black: Colour4 bg_darkest: Colour4 bg_darker: Colour4 bg_dark: Colour4 bg: Colour4 bg_light: Colour4 bg_lighter: Colour4 bg_input: Colour4 panel_bg: Colour4 header_bg: Colour4 toolbar_bg: Colour4 status_bar_bg: Colour4 section_bg: Colour4 text: Colour4 text_bright: Colour4 text_label: Colour4 text_dim: Colour4 text_muted: Colour4 text_faint: Colour4 accent: Colour4 error: Colour4 warning: Colour4 success: Colour4 info: Colour4 selection: Colour4 selection_bg: Colour4 hover_bg: Colour4 highlight: Colour4 border: Colour4 border_light: Colour4 btn_bg: Colour4 btn_hover: Colour4 btn_pressed: Colour4 btn_border: Colour4 btn_primary: Colour4 btn_danger: Colour4 input_border: Colour4 input_focus: Colour4 placeholder: Colour4 scrollbar_hover: Colour4 scrollbar_track: Colour4 tab_bg: Colour4 tab_active: Colour4 tab_hover: Colour4 tab_text: Colour4 tab_active_text: Colour4 tree_bg: Colour4 tree_select: Colour4 tree_hover: Colour4 tree_arrow: Colour4 check_colour: Colour4 check_box: Colour4 slider_fill: Colour4 slider_handle: Colour4 dock_title_bg: Colour4 dock_title_text: Colour4 popup_bg: Colour4 popup_hover: Colour4 popup_separator: Colour4 divider: Colour4 divider_hover: Colour4 current_line: Colour4 bracket_match: Colour4 bracket_mismatch: Colour4 line_number: Colour4 gutter_bg: Colour4 syntax: SyntaxTheme viewport_bg: Colour4 gizmo_x: Colour4 gizmo_y: Colour4 gizmo_z: Colour4 selection_outline: Colour4 grid_major: Colour4 grid_minor: Colour4 minimap_text: Colour4 minimap_keyword: Colour4 minimap_string: Colour4 minimap_comment: Colour4 autocomplete_bg: Colour4 autocomplete_selected: Colour4 autocomplete_border: Colour4 autocomplete_hover: Colour4 autocomplete_dim: Colour4 autocomplete_kind: Colour4 scrollbar_bg: Colour4 scrollbar_fg: Colour4 header_h: float row_h: float tab_h: float font_size: float ui_scale: float scrollbar_width: float dock_title_h: float btn_style_normal: StyleBox btn_style_hover: StyleBox btn_style_pressed: StyleBox btn_style_disabled: StyleBox btn_style_focused: StyleBox panel_style: StyleBox input_style_normal: StyleBox input_style_focused: StyleBox input_style_disabled: StyleBox tab_style_normal: StyleBox tab_style_active: StyleBox tab_style_hover: StyleBox popup_style: StyleBox popup_style_hover: StyleBox dock_title_style: StyleBox def __init__( self, palette: dict[str, Any] | None = None, stylebox_cfg: StyleBoxConfig | None = None, ) -> None: super().__init__() self._palette = palette if palette is not None else _DARK_PALETTE for key, value in self._palette.items(): setattr(self, key, value) for key, value in _DEFAULT_SIZES.items(): setattr(self, key, value) self._init_styleboxes(stylebox_cfg if stylebox_cfg is not None else _DARK_STYLEBOX) self._sync_dicts() # -- StyleBox helpers ---------------------------------------------------- @staticmethod def _lighten(c: Colour4, amount: float = 0.08) -> Colour4: return (min(c[0] + amount, 1.0), min(c[1] + amount, 1.0), min(c[2] + amount, 1.0), c[3]) @staticmethod def _darken(c: Colour4, amount: float = 0.06) -> Colour4: return (max(c[0] - amount, 0.0), max(c[1] - amount, 0.0), max(c[2] - amount, 0.0), c[3]) def _init_styleboxes(self, cfg: StyleBoxConfig) -> None: """Build all StyleBox attributes from the colour palette and the supplied strategy.""" L, D = self._lighten, self._darken bb, bh, bp = self.btn_bg, self.btn_hover, self.btn_pressed EL, ED = cfg.emboss_light, cfg.emboss_dark ga = cfg.gradient_amount hover_b = self.accent if cfg.hover_uses_accent else self.btn_border pressed_b = self.accent if cfg.pressed_uses_accent else self.btn_border if cfg.button_style == "embossed_classic": for state, bg in (("normal", bb), ("hover", bh)): setattr(self, f"btn_style_{state}", StyleBox( bg_colour=bg, border_width=1.0, content_margin=2.0, border_top=L(bg, EL), border_left=L(bg, EL), border_bottom=D(bg, ED), border_right=D(bg, ED), )) self.btn_style_pressed = StyleBox( bg_colour=bp, border_width=1.0, content_margin=2.0, border_top=D(bp, ED), border_left=D(bp, ED), border_bottom=L(bp, EL), border_right=L(bp, EL), ) elif cfg.button_style == "embossed_subtle": self.btn_style_normal = StyleBox( bg_colour=bb, border_width=1.0, content_margin=2.0, border_top=L(bb, EL), border_left=L(bb, EL), border_bottom=D(bb, ED), border_right=D(bb, ED), ) self.btn_style_hover = StyleBox( bg_colour=bh, border_width=1.0, content_margin=2.0, border_colour=hover_b, ) self.btn_style_pressed = StyleBox( bg_colour=bp, border_width=1.0, content_margin=2.0, border_colour=pressed_b, ) elif cfg.button_style == "gradient": self.btn_style_normal = StyleBox( bg_colour=bb, border_width=1.0, content_margin=2.0, bg_gradient=(L(bb, ga), D(bb, ga)), border_colour=self.btn_border, ) self.btn_style_hover = StyleBox( bg_colour=bh, border_width=1.0, content_margin=2.0, bg_gradient=(L(bh, ga), D(bh, ga)), border_colour=hover_b, ) self.btn_style_pressed = StyleBox( bg_colour=bp, border_width=1.0, content_margin=2.0, border_colour=pressed_b, ) else: # "flat" self.btn_style_normal = StyleBox( bg_colour=bb, border_width=1.0, content_margin=2.0, border_colour=self.btn_border, ) self.btn_style_hover = StyleBox( bg_colour=bh, border_width=1.0, content_margin=2.0, border_colour=hover_b, ) self.btn_style_pressed = StyleBox( bg_colour=bp, border_width=1.0, content_margin=2.0, border_colour=pressed_b, ) self.btn_style_disabled = StyleBox( bg_colour=D(bb, cfg.disabled_bg_darken), border_width=1.0, content_margin=2.0, border_colour=D(self.btn_border, cfg.disabled_border_darken), ) self.btn_style_focused = StyleBox( bg_colour=bb, border_width=1.0, content_margin=2.0, border_colour=self.accent, ) fcm = cfg.flat_content_margin self.panel_style = StyleBox( bg_colour=self.panel_bg, border_width=1.0, content_margin=fcm, border_colour=self.border, ) bi = self.bg_input self.input_style_normal = StyleBox( bg_colour=bi, border_width=1.0, content_margin=4.0, border_colour=self.input_border, ) self.input_style_focused = StyleBox( bg_colour=bi, border_width=1.0, content_margin=4.0, border_colour=self.accent, ) self.input_style_disabled = StyleBox( bg_colour=D(bi, cfg.input_disabled_darken), border_width=1.0, content_margin=4.0, border_colour=D(self.input_border, cfg.disabled_border_darken), ) self.tab_style_normal = StyleBox(bg_colour=self.tab_bg, border_width=0.0, content_margin=fcm) self.tab_style_active = StyleBox(bg_colour=self.tab_active, border_width=0.0, content_margin=fcm) self.tab_style_hover = StyleBox(bg_colour=self.tab_hover, border_width=0.0, content_margin=fcm) self.popup_style = StyleBox( bg_colour=self.popup_bg, border_width=1.0, content_margin=fcm, border_colour=getattr(self, cfg.popup_border_attr), ) self.popup_style_hover = StyleBox(bg_colour=self.popup_hover, border_width=0.0, content_margin=fcm) self.dock_title_style = StyleBox(bg_colour=self.dock_title_bg, border_width=0.0, content_margin=fcm) # -- Dict synchronisation ------------------------------------------------ def _sync_dicts(self) -> None: """Populate ``self.colours`` and ``self.sizes`` from the palette and sizes.""" self.colours.update({k: v for k, v in self._palette.items() if k != "syntax"}) self.sizes.update(_DEFAULT_SIZES) a = self.accent # Legacy / computed entries kept for API compatibility self.colours["text_disabled"] = self.text_dim self.colours["accent_hover"] = (a[0] + 0.1, a[1] + 0.1, min(a[2] + 0.04, 1.0), 1.0) self.colours["accent_pressed"] = (a[0] - 0.06, a[1] - 0.06, a[2] - 0.06, 1.0) self.colours["focus"] = a # -- Factory presets -----------------------------------------------------
[docs] @classmethod def dark(cls) -> AppTheme: """Dark theme (default).""" return cls()
[docs] @classmethod def abyss(cls) -> AppTheme: """Abyss: near-black with a subtle cool-blue tint. OLED-friendly.""" return cls(palette=_ABYSS_PALETTE, stylebox_cfg=_ABYSS_STYLEBOX)
[docs] @classmethod def midnight(cls) -> AppTheme: """Midnight: near-black with a subtle warm-green tint. OLED-friendly.""" return cls(palette=_MIDNIGHT_PALETTE, stylebox_cfg=_MIDNIGHT_STYLEBOX)
[docs] @classmethod def light(cls) -> AppTheme: """Light theme with bright backgrounds and gradient buttons.""" return cls(palette=_LIGHT_PALETTE, stylebox_cfg=_LIGHT_STYLEBOX)
[docs] @classmethod def monokai(cls) -> AppTheme: """Monokai-inspired theme.""" return cls(palette=_MONOKAI_PALETTE, stylebox_cfg=_MONOKAI_STYLEBOX)
[docs] @classmethod def solarised_dark(cls) -> AppTheme: """Solarised Dark: Ethan Schoonover's warm-tinted dark palette.""" return cls(palette=_SOLARISED_DARK_PALETTE, stylebox_cfg=_SOLARISED_DARK_STYLEBOX)
[docs] @classmethod def nord(cls) -> AppTheme: """Nord: Arctic, north-bluish palette by Arctic Ice Studio.""" return cls(palette=_NORD_PALETTE, stylebox_cfg=_NORD_STYLEBOX)
# --------------------------------------------------------------------------- # Module-level singleton # --------------------------------------------------------------------------- _current_theme: AppTheme = AppTheme() _theme_generation: int = 0
[docs] def get_theme() -> AppTheme: """Return the current application theme singleton.""" return _current_theme
[docs] def theme_generation() -> int: """Return the current theme generation counter. Incremented by ``set_theme()``. Draw caches use this to detect global theme changes without walking the widget tree. """ return _theme_generation
[docs] def set_theme(theme: AppTheme) -> None: """Swap the module-level theme singleton and bump the generation counter.""" global _current_theme, _theme_generation _current_theme = theme _theme_generation += 1
[docs] def em(multiple: float) -> float: """Return *multiple* of the theme font size in logical pixels. Use for layout dimensions that should scale with the user's font-size preference. Does NOT multiply by ``ui_scale``: UI layout works in logical (window) coordinates; the GPU rendering pipeline handles the logical-to-physical conversion separately via Draw2DPass. """ return multiple * _current_theme.font_size