"""Semantic roles over the terminal's own 16-colour palette. Colours are never chosen here. A role names a meaning and an ANSI slot, and the user's terminal theme decides what that looks like. Attributes carry real weight: a theme is free to make two slots indistinguishable or to set normal == bright, so bold/dim/underline are a second, independent channel. `dim` in particular is a genuine extra level, not a nicety. """ from __future__ import annotations from dataclasses import dataclass, replace # Rich's names for the 16 ANSI slots, in SGR order. ANSI16: tuple[str, ...] = ( "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "bright_black", "bright_red", "bright_green", "bright_yellow", "bright_blue", "bright_magenta", "bright_cyan", "bright_white", ) @dataclass(frozen=True) class Role: """An ANSI slot plus attributes. ansi="" means the terminal default fg.""" ansi: str = "" attrs: str = "" def style(self) -> str: return " ".join(p for p in (self.attrs, self.ansi) if p) ROLES: dict[str, Role] = { "text": Role(), # terminal default foreground "muted": Role("bright_black"), # timestamps, sizes, hints "rule": Role("bright_black", "dim"), # separators only "accent": Role("green", "bold"), # brand, running, active "ok": Role("green", "dim"), # completed, recedes "warn": Role("yellow", "bold underline"), # needs a look "alert": Role("red", "bold underline"), # never auto-apply "err": Role("red", "bold"), # failed, destructive "old": Role("red"), # outgoing version "new": Role("green", "bold"), # incoming version "ver": Role("cyan"), # neutral version, paths } @dataclass(frozen=True) class Theme: """Resolved role styles, ready to hand to Rich.""" text: str muted: str rule: str accent: str ok: str warn: str alert: str err: str old: str new: str ver: str @classmethod def default(cls) -> Theme: return cls(**{name: role.style() for name, role in ROLES.items()}) def remap(self, **slots: str) -> Theme: """Override slots, e.g. remap(ok="cyan"). Raises on an unknown ANSI slot name so a typo fails loudly at startup instead of silently rendering as the terminal default. """ out = self for name, ansi in slots.items(): if name not in ROLES: raise ValueError(f"unknown role {name!r}") if ansi and ansi not in ANSI16: raise ValueError(f"{name}: {ansi!r} is not an ANSI-16 slot") out = replace(out, **{name: replace(ROLES[name], ansi=ansi).style()}) return out