from __future__ import annotations import sys import tomllib from dataclasses import dataclass from pathlib import Path from typing import Any VALID_SYNTAX_PALETTES: tuple[str, ...] = ("dark", "light", "mariana", "retro_blue") @dataclass class ThemePalette: window_bg: tuple[int, int, int] = (0, 0, 0) child_bg: tuple[int, int, int] = (0, 0, 0) popup_bg: tuple[int, int, int] = (0, 0, 0) border: tuple[int, int, int] = (60, 60, 60) border_shadow: tuple[int, int, int] = (0, 0, 0) frame_bg: tuple[int, int, int] = (45, 45, 45) frame_bg_hovered: tuple[int, int, int] = (60, 60, 60) frame_bg_active: tuple[int, int, int] = (75, 75, 75) title_bg: tuple[int, int, int] = (40, 40, 40) title_bg_active: tuple[int, int, int] = (60, 45, 15) title_bg_collapsed: tuple[int, int, int] = (30, 30, 30) menu_bar_bg: tuple[int, int, int] = (35, 35, 35) scrollbar_bg: tuple[int, int, int] = (30, 30, 30) scrollbar_grab: tuple[int, int, int] = (80, 80, 80) scrollbar_grab_hovered: tuple[int, int, int] = (100, 100, 100) scrollbar_grab_active: tuple[int, int, int] = (120, 120, 120) check_mark: tuple[int, int, int] = (200, 200, 200) slider_grab: tuple[int, int, int] = (60, 60, 60) slider_grab_active: tuple[int, int, int] = (100, 100, 100) button: tuple[int, int, int] = (60, 60, 60) button_hovered: tuple[int, int, int] = (100, 100, 100) button_active: tuple[int, int, int] = (120, 120, 120) header: tuple[int, int, int] = (60, 60, 60) header_hovered: tuple[int, int, int] = (100, 100, 100) header_active: tuple[int, int, int] = (120, 120, 120) separator: tuple[int, int, int] = (60, 60, 60) separator_hovered: tuple[int, int, int] = (100, 100, 100) separator_active: tuple[int, int, int] = (200, 200, 200) resize_grip: tuple[int, int, int] = (60, 60, 60) resize_grip_hovered: tuple[int, int, int] = (100, 100, 100) resize_grip_active: tuple[int, int, int] = (200, 200, 200) tab: tuple[int, int, int] = (60, 60, 60) tab_hovered: tuple[int, int, int] = (100, 100, 100) tab_selected: tuple[int, int, int] = (100, 100, 100) tab_dimmed: tuple[int, int, int] = (60, 60, 60) tab_dimmed_selected: tuple[int, int, int] = (100, 100, 100) docking_preview: tuple[int, int, int] = (100, 100, 100) docking_empty_bg: tuple[int, int, int] = (20, 20, 20) text: tuple[int, int, int] = (200, 200, 200) text_disabled: tuple[int, int, int] = (130, 130, 130) text_selected_bg: tuple[int, int, int] = (60, 100, 150) table_header_bg: tuple[int, int, int] = (55, 55, 55) table_border_strong: tuple[int, int, int] = (60, 60, 60) table_border_light: tuple[int, int, int] = (40, 40, 40) table_row_bg: tuple[int, int, int] = (0, 0, 0) table_row_bg_alt: tuple[int, int, int] = (10, 10, 10) nav_cursor: tuple[int, int, int] = (100, 100, 100) nav_windowing_dim_bg: tuple[int, int, int] = (20, 20, 20) nav_windowing_highlight: tuple[int, int, int] = (200, 200, 200) modal_window_dim_bg: tuple[int, int, int] = (10, 10, 10) plot_lines: tuple[int, int, int] = (100, 100, 100) plot_lines_hovered: tuple[int, int, int] = (200, 100, 100) plot_histogram: tuple[int, int, int] = (100, 100, 100) plot_histogram_hovered: tuple[int, int, int] = (200, 100, 100) drag_drop_target: tuple[int, int, int] = (200, 200, 0) drag_drop_target_bg: tuple[int, int, int] = (0, 0, 0) input_text_cursor: tuple[int, int, int] = (200, 200, 200) tab_dimmed_selected_overline: tuple[int, int, int] = (100, 100, 100) tab_selected_overline: tuple[int, int, int] = (100, 100, 100) text_link: tuple[int, int, int] = (60, 100, 150) tree_lines: tuple[int, int, int] = (60, 60, 60) unsaved_marker: tuple[int, int, int] = (200, 200, 200) # Semantic colors status_success: tuple[int, int, int] = (80, 255, 80) status_warning: tuple[int, int, int] = (255, 152, 48) status_error: tuple[int, int, int] = (255, 72, 64) status_info: tuple[int, int, int] = (0, 255, 255) bubble_user: tuple[int, int, int] = (30, 45, 75) bubble_ai: tuple[int, int, int] = (35, 65, 45) bubble_vendor: tuple[int, int, int] = (65, 55, 30) bubble_system: tuple[int, int, int] = (25, 25, 25) slice_manual: tuple[int, int, int] = (255, 165, 0) slice_auto: tuple[int, int, int] = (0, 255, 0) slice_selection: tuple[int, int, int] = (100, 100, 255) # Diff colors diff_added: tuple[int, int, int] = (51, 230, 51) diff_removed: tuple[int, int, int] = (230, 51, 51) diff_header: tuple[int, int, int] = (77, 178, 255) @classmethod def from_dict(cls, data: dict[str, Any]) -> ThemePalette: kwargs: dict[str, Any] = {} for k, v in data.items(): if hasattr(cls, k) and isinstance(v, (list, tuple)) and len(v) == 3: kwargs[k] = (int(v[0]), int(v[1]), int(v[2])) return cls(**kwargs) def to_dict(self) -> dict[str, Any]: return {k: list(v) for k, v in self.__dict__.items() if isinstance(v, tuple)} @dataclass class ThemeFile: name: str palette: ThemePalette syntax_palette: str source_path: Path scope: str description: str = "" def __post_init__(self) -> None: if self.syntax_palette not in VALID_SYNTAX_PALETTES: raise ValueError( f"invalid syntax_palette '{self.syntax_palette}' in {self.source_path}; " f"must be one of {VALID_SYNTAX_PALETTES}" ) def with_scope(self, scope: str) -> ThemeFile: return ThemeFile( name = self.name, palette = self.palette, syntax_palette = self.syntax_palette, source_path = self.source_path, scope = scope, description = self.description, ) def to_dict(self) -> dict[str, Any]: return { "syntax_palette": self.syntax_palette, "description": self.description, "colors": self.palette.to_dict(), } @classmethod def from_dict(cls, name: str, data: dict[str, Any], source_path: Path, scope: str) -> ThemeFile: if "colors" not in data: raise ValueError( f"theme file {source_path} is missing required [colors] section" ) syntax_palette = data.get("syntax_palette", "dark") if syntax_palette not in VALID_SYNTAX_PALETTES: raise ValueError( f"invalid syntax_palette '{syntax_palette}' in {source_path}; " f"must be one of {VALID_SYNTAX_PALETTES}" ) return cls( name = name, palette = ThemePalette.from_dict(data["colors"]), syntax_palette = syntax_palette, source_path = source_path, scope = scope, description = str(data.get("description", "")), ) def load_theme_file(path: Path, scope: str) -> ThemeFile: if not path.exists(): raise FileNotFoundError(f"theme file not found: {path}") try: with open(path, "rb") as f: data = tomllib.load(f) except Exception as e: raise ValueError(f"failed to parse theme TOML {path}: {e}") from e if not isinstance(data, dict): raise ValueError(f"theme TOML {path} must be a top-level table") name = data.get("name", path.stem) theme = ThemeFile.from_dict(name, data, source_path=path, scope=scope) return theme def load_themes_from_dir(path: Path, scope: str) -> dict[str, ThemeFile]: out: dict[str, ThemeFile] = {} if not path.exists(): return out for child in sorted(path.iterdir()): if not child.is_file(): continue if child.suffix.lower() != ".toml": continue try: theme = load_theme_file(child, scope=scope) except (FileNotFoundError, ValueError) as e: print(f"warning: {e}", file=sys.stderr) continue out[theme.name] = theme return out def load_themes_from_toml(path: Path, scope: str) -> dict[str, ThemeFile]: out: dict[str, ThemeFile] = {} if not path.exists(): return out try: with open(path, "rb") as f: data = tomllib.load(f) except Exception as e: print(f"warning: failed to parse {path}: {e}", file=sys.stderr) return out if not isinstance(data, dict): return out themes_sec = data.get("themes", {}) if not isinstance(themes_sec, dict): return out for name, theme_data in themes_sec.items(): if not isinstance(theme_data, dict): continue try: theme = ThemeFile.from_dict(name, theme_data, source_path=path, scope=scope) except ValueError as e: print(f"warning: {name}: {e}", file=sys.stderr) continue out[name] = theme return out