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