"""Single-cell text widgets that snapshot dynamic text during simulation updates."""
from __future__ import annotations
from collections.abc import Callable
from typing import Literal
from .bruhutil.bruhffer import Buffer
from .grid import _glyph
from .scene import _seconds
from .styling import TextStyle as TextStyle
TextSource = str | Callable[[], str]
TextAlign = Literal["left", "center", "right"]
VerticalAlign = Literal["top", "middle", "bottom"]
TextOverflow = Literal["clip", "ellipsis", "wrap"]
def _validate_text(value: str, *, multiline: bool) -> str:
if not isinstance(value, str):
raise TypeError("Text sources must return strings")
for char in value:
if char == "\n" and multiline:
continue
try:
_glyph(char)
except ValueError as error:
raise ValueError(
"Text must use plain single-cell characters; style with TextStyle"
) from error
return value
def _source(value: TextSource, *, multiline: bool) -> TextSource:
if isinstance(value, str):
_validate_text(value, multiline=multiline)
elif not callable(value):
raise TypeError("Text must be a string or a zero-argument string callback")
return value
def _read(source: TextSource, *, multiline: bool) -> str:
return _validate_text(source() if callable(source) else source, multiline=multiline)
def _clip(text: str, width: int, ellipsis: bool = False) -> str:
if len(text) <= width:
return text
if ellipsis:
return text[: max(0, width - 3)] + "." * min(3, width)
return text[:width]
[docs]
class Label:
"""Aligned plain text, optionally supplied by a live zero-argument callback.
Text is validated/cached on start and once per update, never during draw.
Static text is available immediately; dynamic text starts empty. Newlines
are allowed, but controls, wide/combining glyphs and inline ANSI are not.
wrap breaks at cell boundaries, not words. ellipsis shortens each long line
with ASCII dots. Extra rows clip. fill=True paints the full local buffer
with styled spaces; False leaves unpainted cells untouched. Text spaces paint.
Widgets own no input or external resources. Callbacks should only read state.
"""
[docs]
def __init__(
self,
text: TextSource,
*,
align: TextAlign = "left",
vertical_align: VerticalAlign = "top",
overflow: TextOverflow = "clip",
style: TextStyle | None = None,
fill: bool = True,
) -> None:
self._source = _source(text, multiline=True)
if align not in ("left", "center", "right"):
raise ValueError("align must be left, center, or right")
if vertical_align not in ("top", "middle", "bottom"):
raise ValueError("vertical_align must be top, middle, or bottom")
if overflow not in ("clip", "ellipsis", "wrap"):
raise ValueError("overflow must be clip, ellipsis, or wrap")
if style is not None and not isinstance(style, TextStyle):
raise TypeError("style must be a TextStyle or None")
if type(fill) is not bool:
raise TypeError("fill must be a bool")
self._align, self._vertical, self._overflow = align, vertical_align, overflow
self._style, self._fill = style or TextStyle(), fill
self._text = text if isinstance(text, str) else ""
self._started = False
@property
def text(self) -> str:
"""Most recently cached text; reading this never calls the source."""
return self._text
[docs]
def start(self) -> None:
if not self._started:
self._text = _read(self._source, multiline=True)
self._started = True
[docs]
def update(self, dt: float) -> None:
_seconds(dt, "dt")
if not self._started:
raise RuntimeError("Label must be started before update")
if callable(self._source):
self._text = _read(self._source, multiline=True)
[docs]
def draw(self, buffer: Buffer) -> None:
width, height = buffer.width(), buffer.height()
if self._fill:
buffer.clear_buffer(val=self._style._cell(" "))
if width == 0 or height == 0:
return
rows: list[str] = []
for line in self.text.split("\n", height):
if self._overflow == "wrap":
rows.extend(
line[i : i + width]
for i in range(
0, min(len(line), width * (height - len(rows))), width
)
)
if not line:
rows.append("")
else:
rows.append(_clip(line, width, self._overflow == "ellipsis"))
if len(rows) >= height:
break
rows = rows[:height]
y = {
"top": 0,
"middle": (height - len(rows)) // 2,
"bottom": height - len(rows),
}[self._vertical]
for row, text in enumerate(rows):
x = {
"left": 0,
"center": (width - len(text)) // 2,
"right": width - len(text),
}[self._align]
for column, char in enumerate(text):
buffer.put_char(x + column, y + row, self._style._cell(char))
[docs]
def close(self) -> None:
self._started = False
[docs]
class StatusBar:
"""One row of left/center/right text, refreshed at start and on each update.
Sections are single-line TextSources, styled per cell. Draw never evaluates
callbacks. The right section has first claim on space, then left, then center.
Sections clip from their ends and leave a one-cell gap when space permits;
they never overwrite one another. Only the first local row is painted.
Use Slot(status, size=Fixed(1)) in a Column. No input routing is installed.
"""
[docs]
def __init__(
self,
left: TextSource = "",
*,
center: TextSource = "",
right: TextSource = "",
style: TextStyle | None = None,
) -> None:
self._sources = tuple(
_source(text, multiline=False) for text in (left, center, right)
)
if style is not None and not isinstance(style, TextStyle):
raise TypeError("style must be a TextStyle or None")
self._style = style or TextStyle()
self._texts = tuple(
text if isinstance(text, str) else "" for text in self._sources
)
self._started = False
def _refresh(self) -> None:
self._texts = tuple(_read(source, multiline=False) for source in self._sources)
[docs]
def start(self) -> None:
if not self._started:
self._refresh()
self._started = True
[docs]
def update(self, dt: float) -> None:
_seconds(dt, "dt")
if not self._started:
raise RuntimeError("StatusBar must be started before update")
self._refresh()
[docs]
def draw(self, buffer: Buffer) -> None:
width = buffer.width()
if width == 0 or buffer.height() == 0:
return
for x in range(width):
buffer.put_char(x, 0, self._style._cell(" "))
left, center, right = self._texts
right = _clip(right, width)
rx = width - len(right)
left = _clip(left, max(0, rx - int(bool(right))))
start = len(left) + int(bool(left))
end = rx - int(bool(right))
center = _clip(center, max(0, end - start))
cx = max(start, min((width - len(center)) // 2, end - len(center)))
for x, text in ((0, left), (cx, center), (rx, right)):
for offset, char in enumerate(text):
buffer.put_char(x + offset, 0, self._style._cell(char))
[docs]
def close(self) -> None:
self._started = False