Files
MeshTerm/meshterm/ui/tui/render.py
T
Jean-Pierre MartineauandClaude Fable 5 94aba0cbcd Message paths: a path graph over selectable, sideways-scrolling rows
The ^P view grows from a static scroll window into its own dialog. Up
top, every distinct path the message took drawn in the atlas's edge
style — origin left, us right, the final relay → us link coloured by
that path's reception SNR, earlier links slate — with markers for every
node but labels only along the currently selected path, so a many-path
graph stays readable; the selected path draws bright, the rest dim.
Beneath, one row per arrival with a selection cursor: ↑↓ move it (the
graph follows), and a row longer than the dialog scrolls horizontally
with ←→ — the whole line shifts under an edge …, and snaps back the
moment the selection moves on. crop_cells in tui.render is the new
cell-exact window primitive behind that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:47:56 -04:00

201 lines
7.5 KiB
Python

"""The single Rich-to-prompt_toolkit bridge.
The TUI keeps rendering in Rich (all existing tables, panels, and text widgets are reused
verbatim) and lets prompt_toolkit own the terminal, input, and resize events. This module
is the seam between the two: it renders any Rich renderable to an ANSI string at a chosen
width, which prompt_toolkit then draws via :class:`prompt_toolkit.formatted_text.ANSI`.
Because rendering is redone at the *current* width on every repaint, content reflows
automatically when the terminal is resized.
"""
from __future__ import annotations
from io import StringIO
from rich.cells import cell_len
from rich.console import Console, RenderableType
from rich.text import Text
from ..theme import MESH_THEME
#: Cache one headless render console per width. Consoles are cheap but repaint happens on
#: every keystroke and on the live-monitor timer, so caching avoids needless churn. Keyed
#: by width because a console's width is fixed at construction.
_CONSOLES: dict[int, Console] = {}
def _console(width: int) -> Console:
"""Return a themed, headless console that emits ANSI at the given width.
Args:
width: Target render width in columns.
Returns:
A cached :class:`rich.console.Console` writing to an internal buffer.
"""
console = _CONSOLES.get(width)
if console is None:
console = Console(
theme=MESH_THEME,
width=width,
file=StringIO(),
force_terminal=True,
color_system="truecolor",
highlight=False,
soft_wrap=False,
)
_CONSOLES[width] = console
return console
def render_to_ansi(renderable: RenderableType, width: int, *, no_wrap: bool = False) -> str:
"""Render a Rich renderable to an ANSI string at ``width`` columns.
Args:
renderable: Any Rich renderable (table, panel, text, group, markup string).
width: Target width in columns; content wraps/pads to it.
no_wrap: When ``True``, keep the content on a single line — show what fits and
crop the overflow (with an ellipsis) instead of wrapping onto more rows.
Returns:
The rendered output as an ANSI-escaped string, without a trailing newline.
"""
width = max(1, width)
console = _console(width)
with console.capture() as capture:
if no_wrap:
console.print(renderable, end="", no_wrap=True, overflow="ellipsis", crop=True)
else:
console.print(renderable, end="")
return capture.get()
def render_lines(renderable: RenderableType, width: int, *, no_wrap: bool = False) -> list[str]:
"""Render a Rich renderable to a list of ANSI lines at ``width`` columns.
Each returned line is an independently styled ANSI string, so the caller can slice a
vertical viewport (for scrolling) without splitting escape sequences.
Args:
renderable: Any Rich renderable.
width: Target width in columns.
no_wrap: When ``True``, keep the content to a single cropped line (see
:func:`render_to_ansi`).
Returns:
The rendered lines, newline-free. A trailing empty line (from the final newline)
is dropped so line counts match visible rows.
"""
ansi = render_to_ansi(renderable, width, no_wrap=no_wrap)
lines = ansi.split("\n")
if lines and lines[-1] == "":
lines.pop()
return lines
def right_aligned_tail(body: Text, tail: Text, width: int) -> Text:
"""Lay ``body`` out with ``tail`` pinned to the right edge of its last line.
``body`` wraps at ``width`` as usual; ``tail`` — a short status such as the chat
compose bar's byte counter — is right-aligned on ``body``'s final wrapped line when
it still fits there (after at least one blank cell), otherwise on a new line of its
own, also right-aligned. So the tail reads as a steady gauge in the corner rather
than trailing the cursor and wrapping along with the text.
Args:
body: The wrappable leading content (e.g. the input line, cursor block included).
tail: The short run to pin to the right edge.
width: Total render width in columns.
Returns:
A single :class:`Text` with embedded newlines, ready for :func:`render_lines`.
"""
width = max(1, width)
console = _console(width)
lines = list(body.wrap(console, width)) or [Text("")]
combined = Text()
for line in lines[:-1]:
combined.append_text(line)
combined.append("\n")
last = lines[-1]
gap = width - last.cell_len - tail.cell_len
if gap >= 1:
combined.append_text(last)
combined.append(" " * gap)
else:
combined.append_text(last)
combined.append("\n")
combined.append(" " * max(0, width - tail.cell_len))
combined.append_text(tail)
return combined
def crop_cells(text: Text, start: int, width: int) -> Text:
"""Cut a single-line styled Text to the cell window ``[start, start + width)``.
The horizontal-scroll primitive: a row wider than its lane is shown ``width``
cells at a time, shifted ``start`` cells in, styling preserved. Boundaries are
measured in display cells, so a double-width glyph straddling either edge is
dropped (replaced by a pad space on the left) rather than half-shown.
Args:
text: The styled line to crop (treated as a single line).
start: Cells to skip from the left (clamped at 0).
width: Cells the window spans.
Returns:
A new :class:`Text` at most ``width`` cells wide, ``no_wrap`` set so the
caller can drop it straight into a row.
"""
start = max(0, start)
plain = text.plain
index, pad = len(plain), 0
if start == 0:
index = 0
else:
acc = 0
for i, ch in enumerate(plain):
w = cell_len(ch)
if acc + w > start:
# This character crosses the cut: keep it when it starts exactly at
# the boundary, else drop it and pad for its protruding half.
index, pad = (i, 0) if acc == start else (i + 1, acc + w - start)
break
acc += w
out = Text(" " * pad, no_wrap=True)
out.append_text(text[index:])
out.truncate(max(0, width))
return out
def render_hanging(prefix: Text, body: Text, width: int, *, indent: int) -> list[str]:
"""Render ``prefix + body`` at ``width``, wrapping the body with a hanging indent.
The first visual line carries ``prefix`` followed by the body; every wrapped
continuation line is padded by ``indent`` columns so it aligns under the body rather
than falling back to column zero. Used by the chat transcript so a wrapped message
lines up with its own first line instead of with the timestamp gutter.
Args:
prefix: The leading run (e.g. a pointer + timestamp) shown once, on the first line.
body: The wrappable message text; styling (mentions, glyphs) is preserved.
width: Total render width in columns.
indent: Columns to indent continuation lines by (typically ``prefix.cell_len``).
Returns:
The rendered lines, newline-free.
"""
width = max(1, width)
console = _console(width)
avail = max(1, width - indent)
wrapped = list(body.wrap(console, avail)) or [Text("")]
pad = " " * indent
combined = Text()
for i, line in enumerate(wrapped):
if i:
combined.append("\n")
combined.append_text(prefix if i == 0 else Text(pad))
combined.append_text(line)
return render_lines(combined, width)