git/orchard/main
updater/layout.py
153 lines5 KB last change raw · history
"""Fixed-column layout.

Every emitted line goes through a Row. The Row knows the terminal width and
refuses to produce a line that overflows it, because the failure mode of
hand-padding with f-strings is that columns drift silently and a footer wraps
onto a second line, and neither shows up until you render the thing.
"""

from __future__ import annotations

from dataclasses import dataclass

from rich.cells import cell_len
from rich.text import Text


class Overflow(Exception):
    """A line exceeded the grid. Always a bug in the caller, never the user's."""


@dataclass(frozen=True)
class Grid:
    """A column grid. All renderers share one so screens line up with each other."""

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

    @property
    def right(self) -> int:
        """Column at which right-aligned content ends."""
        return self.width - self.right_margin

    def row(self, indent: int | None = None) -> Row:
        return Row(self, self.indent if indent is None else indent)

    def blank(self) -> Text:
        return Text()


def clip(text: str, limit: int) -> str:
    """Truncate to a cell budget, counting display width rather than codepoints."""
    if limit <= 0:
        return ""
    if cell_len(text) <= limit:
        return text
    if limit == 1:
        return "…"
    out: list[str] = []
    used = 0
    for char in text:
        width = cell_len(char)
        if used + width > limit - 1:
            break
        out.append(char)
        used += width
    return "".join(out) + "…"


class Row:
    """Builds one line on the grid. Chainable.

        Row(grid).span("linux", t.text).to(36).span(old, t.old).to(56).span(new, t.new)

    The primitives cannot emit a line wider than the grid. Content that does not
    fit is clipped and counted in `clipped`: handing the terminal a line it will
    wrap corrupts every row below it, but silently losing text is also a bug, so
    the counter lets tests tell "rendered" from "rendered by luck".
    """

    __slots__ = ("_text", "clipped", "grid")

    def __init__(self, grid: Grid, indent: int) -> None:
        self.grid = grid
        self._text = Text(" " * max(0, min(indent, grid.width)))
        self.clipped = 0

    @property
    def at(self) -> int:
        """Current column, i.e. the display width of what we have so far.

        Measured in terminal cells rather than codepoints: a double-width glyph
        would otherwise satisfy the overflow check while still corrupting every
        column to its right.
        """
        return self._text.cell_len

    def _room(self, upto: int) -> int:
        return max(0, min(upto, self.grid.width) - self.at)

    def span(self, text: str, style: str | None = None) -> Row:
        if not text:
            return self
        room = self._room(self.grid.width)
        if cell_len(text) > room:
            text = clip(text, room)
            self.clipped += 1
        if text:
            self._text.append(text, style=style or "")
        return self

    def to(self, col: int, minimum: int = 1) -> Row:
        """Pad with spaces until the cursor sits at absolute column `col`."""
        want = max(minimum, col - self.at)
        room = self._room(self.grid.width)
        if want > room:
            want = room
            self.clipped += 1
        self._text.append(" " * want)
        return self

    def right(self, text: str, style: str | None = None, col: int | None = None) -> Row:
        """Right-align `text` so it ends at `col` (default: the right margin)."""
        end = min(self.grid.right if col is None else col, self.grid.width)
        room = self._room(end)
        if room <= 0 or not text:
            if text:
                self.clipped += 1
            return self
        # keep at least one cell of separation wherever there is room for it,
        # otherwise the value collides with whatever precedes it
        if cell_len(text) > room - 1:
            text = clip(text, room - 1 if room > 1 else room)
            self.clipped += 1
            if not text:
                return self
        self._text.append(" " * (room - cell_len(text)))
        self._text.append(text, style=style or "")
        return self

    def fill(self, char: str = "─", style: str | None = None) -> Row:
        """Fill the remaining grid width with a repeating character."""
        room = self._room(self.grid.width)
        if room > 0:
            self._text.append(char * room, style=style or "")
        return self

    def done(self) -> Text:
        if self.at > self.grid.width:
            raise Overflow(
                f"line is {self.at} cells, grid is {self.grid.width}: {self._text.plain!r}"
            )
        return self._text


def check(lines: list[Text], width: int) -> None:
    """Validate a whole screen, by display width. Call this in tests on every screen."""
    for i, line in enumerate(lines):
        if cell_len(line.plain) > width:
            raise Overflow(
                f"line {i} is {cell_len(line.plain)} cells, grid is {width}: {line.plain!r}"
            )