git/orchard/main
updater/render/base.py
199 lines6 KB last change raw · history
"""Screen scaffolding shared by every renderer.

Column geometry lives in exactly one place. A renderer never formats a line with
f-strings and hopes; it advances a Row to an absolute column, and any line that
would overflow the terminal raises instead of wrapping.

Columns are derived from the width rather than hardcoded, so a narrower terminal
degrades instead of corrupting every row to the right of the first long name.
"""

from __future__ import annotations

from dataclasses import dataclass, field

from rich.text import Text

from ..icons import Glyphs
from ..layout import Grid, Row
from ..theme import Theme

#: below this the multi-column layouts cannot be honest, and callers should fall
#: back to a single-column form rather than truncate silently
MIN_WIDTH = 64


@dataclass(frozen=True)
class Columns:
    """Absolute column positions for a given terminal width."""

    width: int = 100
    indent: int = 2
    right_margin: int = 2

    @classmethod
    def for_width(cls, width: int) -> Columns:
        return cls(width=max(int(width), 20))

    @property
    def grid(self) -> int:
        """Width used for *placing* columns, with a floor.

        Inflating `width` itself was the bug: every Row then believed it had 64
        cells to play with on a 40-column terminal, so each line ran four cells
        past the edge and the terminal wrapped it. The floor belongs here, where
        it only stops the column positions collapsing into each other.
        """
        return max(self.width, MIN_WIDTH)

    @property
    def narrow(self) -> bool:
        return self.width < 88

    @property
    def right(self) -> int:
        return self.width - self.right_margin

    def _at(self, fraction: float) -> int:
        span = self.grid - self.indent
        # never place a column past the real edge
        return min(self.indent + int(span * fraction), max(0, self.width - 2))

    #: package rows: name | old -> new | note | right-aligned value
    @property
    def ver(self) -> int:
        return self._at(0.55)

    @property
    def note(self) -> int:
        return self._at(0.76)

    #: feed rows (indented under a section heading)
    @property
    def feed_indent(self) -> int:
        return self.indent + 4

    @property
    def feed_ver(self) -> int:
        return self._at(0.46)

    @property
    def feed_note(self) -> int:
        return self._at(0.74)

    #: tree rows: 3 extra cells per level of nesting
    def tree_name(self, depth: int) -> int:
        return self.indent + 3 + 3 * depth


@dataclass
class Screen:
    """An accumulating screen: theme, glyphs, and one shared column grid."""

    theme: Theme
    glyphs: Glyphs
    columns: Columns = field(default_factory=Columns)
    lines: list[Text] = field(default_factory=list)
    #: how many cells the primitives had to clip. Zero means everything fitted
    #: on its own merits; tests assert this for well-formed input.
    clipped: int = 0

    # -- structure ---------------------------------------------------------

    def add(self, line: Text) -> Text:
        self.lines.append(line)
        return line

    def blank(self) -> None:
        self.lines.append(Text())

    def rule(self) -> None:
        self.add(self.row().fill("─", self.theme.rule).done())

    def header(self, meta: str, brand: str = "updater") -> None:
        self.add(self.row().span(brand, self.theme.accent).right(meta, self.theme.muted).done())
        self.rule()

    def section(self, title: str, meta: str = "") -> None:
        self.blank()
        row = self.row().span(title.upper(), self.theme.text or None)
        if meta:
            row.right(meta, self.theme.muted)
        self.add(row.done())
        self.blank()

    def hint(self, text: str, keys: str) -> None:
        """A right-aligned keybinding hint on the last line of a screen."""
        self.blank()
        self.add(self.row().span(text, self.theme.muted).right(keys, self.theme.muted).done())

    # -- rows --------------------------------------------------------------

    def row(self, indent: int | None = None) -> Row:
        grid = Grid(
            width=self.columns.width,
            indent=self.columns.indent,
            right_margin=self.columns.right_margin,
        )
        return grid.row(indent)

    def finish(self, row: Row) -> Row:
        self.clipped += row.clipped
        self.add(row.done())
        return row

    # -- output ------------------------------------------------------------

    def render(self) -> list[Text]:
        """Validate every line against the width and return them."""
        if self.lines:
            widest = max(line.cell_len for line in self.lines)
            if widest > self.columns.width:
                offender = next(l for l in self.lines if l.cell_len == widest)
                raise Overflow(
                    f"line is {widest} cells, width is {self.columns.width}: {offender.plain!r}"
                )
        return self.lines

    def text(self) -> str:
        return "\n".join(line.plain for line in self.render())


# re-exported so renderers import one name for both
from ..layout import Overflow  # noqa: E402,F401

# --------------------------------------------------------------------------
# small formatting helpers used by several screens
# --------------------------------------------------------------------------


def human_bytes(size: int, *, signed: bool = False) -> str:
    """Human-readable size. Keeps a negative sign, and with `signed` marks a
    positive too, because an on-disk delta is a change either way."""
    sign = "-" if size < 0 else ("+" if signed and size > 0 else "")
    value = abs(size)
    for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
        if value < 1024 or unit == "TiB":
            if unit == "B":
                return f"{sign}{value} B"
            return f"{sign}{value:.1f} {unit}" if value < 100 else f"{sign}{value:.0f} {unit}"
        value /= 1024
    return f"{sign}{value:.1f} TiB"


def human_age(seconds: float) -> str:
    """Rough age, for "your databases are this stale" style messages."""
    if seconds < 90:
        return f"{int(seconds)}s"
    if seconds < 5400:
        return f"{int(seconds / 60)}m"
    if seconds < 172800:
        return f"{seconds / 3600:.0f}h"
    return f"{seconds / 86400:.0f}d"


def truncate(text: str, limit: int) -> str:
    if limit <= 1 or len(text) <= limit:
        return text
    return text[: limit - 1] + "…"