From e2f698c4a39c974682b8d222e9f4e8ff8559d61b Mon Sep 17 00:00:00 2001 From: Ed_ Date: Thu, 4 Jun 2026 22:31:22 -0400 Subject: [PATCH] feat(theme-models): add ThemePalette/ThemeFile schema with TOML loader --- src/theme_models.py | 172 ++++++++++++++++++++ tests/fixtures/themes/minimal.toml | 6 + tests/fixtures/themes/missing_required.toml | 2 + tests/test_theme_models.py | 48 ++++++ 4 files changed, 228 insertions(+) create mode 100644 src/theme_models.py create mode 100644 tests/fixtures/themes/minimal.toml create mode 100644 tests/fixtures/themes/missing_required.toml create mode 100644 tests/test_theme_models.py diff --git a/src/theme_models.py b/src/theme_models.py new file mode 100644 index 00000000..83133033 --- /dev/null +++ b/src/theme_models.py @@ -0,0 +1,172 @@ +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) + text: tuple[int, int, int] = (200, 200, 200) + text_disabled: tuple[int, int, int] = (130, 130, 130) + 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) + 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) + 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) + 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) + text_selected_bg: tuple[int, int, int] = (60, 100, 150) + table_header_bg: tuple[int, int, int] = (55, 55, 55) + + @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 = 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 diff --git a/tests/fixtures/themes/minimal.toml b/tests/fixtures/themes/minimal.toml new file mode 100644 index 00000000..6fb93e47 --- /dev/null +++ b/tests/fixtures/themes/minimal.toml @@ -0,0 +1,6 @@ +syntax_palette = "dark" + +[colors] +window_bg = [10, 20, 30] +text = [200, 200, 200] +button_hovered = [255, 100, 50] diff --git a/tests/fixtures/themes/missing_required.toml b/tests/fixtures/themes/missing_required.toml new file mode 100644 index 00000000..1dcc7ca9 --- /dev/null +++ b/tests/fixtures/themes/missing_required.toml @@ -0,0 +1,2 @@ +# missing [colors] section +syntax_palette = "dark" diff --git a/tests/test_theme_models.py b/tests/test_theme_models.py new file mode 100644 index 00000000..b3e88459 --- /dev/null +++ b/tests/test_theme_models.py @@ -0,0 +1,48 @@ +from pathlib import Path +import pytest +from src.theme_models import ThemeFile, ThemePalette, load_theme_file + + +FIXTURES = Path(__file__).parent / "fixtures" / "themes" + + +def test_load_minimal_theme_file(): + p = FIXTURES / "minimal.toml" + theme = load_theme_file(p, scope="project") + assert theme.syntax_palette == "dark" + assert theme.palette.window_bg == (10, 20, 30) + assert theme.palette.text == (200, 200, 200) + assert theme.palette.button_hovered == (255, 100, 50) + assert theme.source_path == p + assert theme.scope == "project" + + +def test_missing_required_keys_raises(): + p = FIXTURES / "missing_required.toml" + with pytest.raises(ValueError, match=r"missing required \[colors\]"): + load_theme_file(p, scope="project") + + +def test_invalid_syntax_palette_raises(): + p = FIXTURES / "minimal.toml" + with pytest.raises(ValueError, match=r"invalid syntax_palette"): + ThemeFile(name="x", palette=ThemePalette(), syntax_palette="not_a_real_palette", source_path=p, scope="project") + + +def test_round_trip_to_from_dict(): + p = FIXTURES / "minimal.toml" + loaded = load_theme_file(p, scope="project") + dumped = loaded.to_dict() + reloaded = ThemeFile.from_dict(loaded.name, dumped, source_path=p, scope="project") + assert reloaded.syntax_palette == loaded.syntax_palette + assert reloaded.palette.window_bg == loaded.palette.window_bg + assert reloaded.palette.text == loaded.palette.text + + +def test_scope_setter(): + p = FIXTURES / "minimal.toml" + theme = load_theme_file(p, scope="global") + assert theme.scope == "global" + themed_as_project = theme.with_scope("project") + assert themed_as_project.scope == "project" + assert themed_as_project.name == theme.name