Private
Public Access
0
0
Files
manual_slop/src/theme_models.py
T

222 lines
8.8 KiB
Python

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