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