git/orchard/main
updater/theme.py
96 lines3 KB last change raw · history
"""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