feat(theme-models): add ThemePalette/ThemeFile schema with TOML loader
This commit is contained in:
@@ -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
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
syntax_palette = "dark"
|
||||
|
||||
[colors]
|
||||
window_bg = [10, 20, 30]
|
||||
text = [200, 200, 200]
|
||||
button_hovered = [255, 100, 50]
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# missing [colors] section
|
||||
syntax_palette = "dark"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user