git/orchard/main
updater/icons.py
138 lines4 KB last change raw · history
"""Icon tiers, selected from context.

The honest answer to "can I use Nerd Font glyphs?" depends on where the terminal
actually lives, and guessing wrong produces tofu.

  nerd     private-use Nerd Font glyphs. Needs a Nerd Font installed *and* a
           terminal we can reasonably expect to render it.
  unicode  geometric Unicode every monospace font carries (default fallback).
  ascii    dumb terminals, no UTF-8, NO_ICONS.

Every state glyph is exactly one cell wide in every tier, because the layout is a
fixed grid and a two-cell glyph silently breaks every column to its right.
"""

from __future__ import annotations

import functools
import os
import subprocess
import sys
from dataclasses import dataclass


@dataclass(frozen=True)
class Glyphs:
    done: str
    run: str
    wait: str
    fail: str
    shared: str
    warn: str
    arrow: str
    branch: str
    last: str
    pipe: str


# nf-fa-check, nf-fa-play, nf-fa-clock_o, nf-fa-times, nf-fa-refresh,
# nf-fa-exclamation_triangle, nf-fa-arrow_right
NERD = Glyphs(
    done="\uf00c",
    run="\uf04b",
    wait="\uf017",
    fail="\uf00d",
    shared="\uf021",
    warn="\uf071",
    arrow="\uf061",
    branch="├─",
    last="└─",
    pipe="│",
)

UNICODE = Glyphs(
    done="✓",
    run="▶",
    wait="·",
    fail="✗",
    shared="↻",
    warn="!",
    arrow="→",
    branch="├─",
    last="└─",
    pipe="│",
)

ASCII = Glyphs(
    done="+",
    run=">",
    wait=".",
    fail="x",
    shared="~",
    warn="!",
    arrow=">",
    branch="|-",
    last="`-",
    pipe="|",
)

TIERS = {"nerd": NERD, "unicode": UNICODE, "ascii": ASCII}


@functools.lru_cache(maxsize=1)
def has_nerd_font() -> bool:
    """Whether any Nerd Font family is installed on *this* host.

    A proxy, not proof: it cannot see the font at the far end of an SSH session,
    which is why the ssh/tmux checks below take precedence.
    """
    try:
        out = subprocess.run(
            ["fc-list"], capture_output=True, text=True, errors="replace", timeout=5, check=False
        )
    except (OSError, subprocess.SubprocessError):
        return False
    return "nerd font" in out.stdout.lower()


def detect(
    env: dict[str, str] | None = None,
    *,
    is_tty: bool | None = None,
    nerd_font: bool | None = None,
    override: str | None = None,
) -> tuple[str, str]:
    """Pick an icon tier. Returns (tier, reason) so --debug can explain itself."""
    env = os.environ if env is None else env
    is_tty = sys.stdout.isatty() if is_tty is None else is_tty
    nerd_font = has_nerd_font() if nerd_font is None else nerd_font

    override = override or env.get("UPDATER_ICONS") or None
    if override:
        if override not in TIERS:
            raise ValueError(f"UPDATER_ICONS={override!r}; expected one of {list(TIERS)}")
        return override, "explicit override"

    if env.get("NO_ICONS"):
        return "ascii", "NO_ICONS set"
    if not is_tty or env.get("TERM", "") in ("", "dumb"):
        return "ascii", "not an interactive terminal"
    enc = (getattr(sys.stdout, "encoding", None) or "").lower()
    if "utf" not in enc:
        return "ascii", f"stdout encoding is {enc or 'unknown'}"

    # PUA codepoints are the first thing to break over a link or a multiplexer,
    # and neither can tell us what the far-end font is.
    if env.get("SSH_CONNECTION") or env.get("SSH_TTY") or env.get("SSH_CLIENT"):
        return "unicode", "remote session (ssh)"
    if env.get("TMUX") or env.get("STY"):
        return "unicode", "multiplexer (tmux/screen)"

    if not nerd_font:
        return "unicode", "no Nerd Font installed"
    return "nerd", "local terminal with Nerd Font installed"


def glyphs(tier: str) -> Glyphs:
    return TIERS[tier]