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