from __future__ import annotations from dataclasses import dataclass from importlib.resources import files from importlib.resources.abc import Traversable from pathlib import PurePosixPath ASSETS: Traversable = files("zshell") / "assets" / "schemes" @dataclass(frozen=True) class SchemeVariant: id: str name: str modes: frozenset[str] accents: tuple[str, ...] = () @dataclass(frozen=True) class SchemeMeta: id: str name: str variants: tuple[SchemeVariant, ...] @dataclass class Palette: colors: dict[str, str] mode: str scheme: str variant: str accent: str | None = None def _parse_txt(path: Traversable) -> dict[str, str]: colors: dict[str, str] = {} for line in path.read_text().splitlines(): line = line.strip() if not line or line.startswith("#"): continue parts = line.split(None, 1) if len(parts) == 2: key, val = parts colors[key] = f"#{val}" if not val.startswith("#") else val return colors def _discover_schemes() -> dict[str, SchemeMeta]: schemes: dict[str, SchemeMeta] = {} for scheme_dir in sorted(ASSETS.iterdir(), key=lambda p: p.name): if not scheme_dir.is_dir() or scheme_dir.name.startswith("."): continue sid = scheme_dir.name display_name = sid.capitalize() variants: list[SchemeVariant] = [] for var_dir in sorted(scheme_dir.iterdir(), key=lambda p: p.name): if not var_dir.is_dir() or var_dir.name.startswith("."): continue modes: set[str] = set() accents: set[str] = set() for f in var_dir.iterdir(): name = PurePosixPath(f.name) if name.suffix != ".txt": continue stem = name.stem if "-" in stem: maybe_accent, maybe_mode = stem.rsplit("-", 1) if maybe_mode in ("dark", "light"): modes.add(maybe_mode) accents.add(maybe_accent) else: modes.add(stem) else: if stem in ("dark", "light"): modes.add(stem) if modes: vname = var_dir.name.capitalize() variants.append( SchemeVariant( id=var_dir.name, name=vname, modes=frozenset(modes), accents=tuple(sorted(accents)), ) ) schemes[sid] = SchemeMeta( id=sid, name=display_name, variants=tuple(variants), ) return schemes SCHEMES: dict[str, SchemeMeta] = _discover_schemes() def get_palette( scheme: str, variant: str, mode: str, accent: str | None = None ) -> Palette: if scheme not in SCHEMES: raise KeyError( f"Unknown scheme '{scheme}'. Available: {', '.join(SCHEMES)}" ) meta = SCHEMES[scheme] var_ids = {v.id for v in meta.variants} if variant not in var_ids: raise KeyError( f"Unknown variant '{variant}' for scheme '{scheme}'. Available: {', '.join(sorted(var_ids))}" ) filename = f"{accent}-{mode}.txt" if accent else f"{mode}.txt" txt_path = ASSETS / scheme / variant / filename if not txt_path.is_file(): txt_path = ASSETS / scheme / variant / f"{mode}.txt" if not txt_path.is_file(): var_info = next(v for v in meta.variants if v.id == variant) raise FileNotFoundError( f"No {mode} palette for '{scheme}:{variant}'. Available modes: {sorted(var_info.modes)}" ) colors = _parse_txt(txt_path) return Palette( colors=colors, mode=mode, scheme=scheme, variant=variant, accent=accent ) def list_schemes() -> dict[str, SchemeMeta]: return dict(SCHEMES) def resolve_preset(spec: str) -> tuple[str, str]: parts = spec.split(":") if len(parts) == 2: return parts[0], parts[1] if len(parts) == 1: return parts[0], "default" raise ValueError(f"Invalid preset spec '{spec}'. Use :")