"""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