"""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] + "…"