Files
MeshTerm/meshterm/ui/tui/render.py
T
Jean-Pierre MartineauandClaude Opus 5 cbdabef174 Find query: put it where the platform can actually show it
The map and the mesh walk echo the query you are typing in their footer
hint. The PicoCalc draws no footer hint -- the F-key lane took that row
-- so on the device both screens silently filtered themselves while the
reader had no way to see what they had typed.

The app already had an answer: the select list and the path composer
each draw the query as a warn-styled /query line above the rows it
narrows. That is now render.query_line, one definition, and the map and
the walk draw it too -- but only where the footer isn't, so the desktop
is untouched and no screen ever shows the query twice.

The row it costs comes from what the query narrows: the walk's link
list, the map's canvas. The map rebuilds its viewport a row shorter
through the ordinary resize path, so beginning a find loses a strip of
ground along the bottom rather than pushing the last row out of frame.

base_body_size was still mirroring only compose_base's bordered branch,
so a screen sizing itself against it left one row of a borderless frame
permanently blank -- which is the row the echo now wants back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 17:35:20 -04:00

316 lines
13 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 collections import OrderedDict
from io import StringIO
from typing import Optional
from rich.cells import cell_len
from rich.console import Console, RenderableType
from rich.text import Text
from ...platforms import Platform, on_platform
from ..theme import active_theme, fold_text
#: 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; emptied whenever the
#: platform switches, since the theme and colour system are baked in at construction.
_CONSOLES: dict[int, Console] = {}
#: The colour depth the rasterizer emits at — bound per platform. ``"standard"`` on the
#: 16-slot console: the theme's ``color(N)`` styles pass through as exact slot indices,
#: and any stray truecolor (a node hue, a map feature) is downsampled by Rich to the
#: nearest conventional slot — which the palette deliberately keeps hue-aligned (see
#: ``theme._VT_SLOTS``), so even strays land in their own colour family.
_COLOR_SYSTEM = "truecolor"
#: Memoized ANSI per (content, width) for the *describable* renderables — a ``str`` or a
#: :class:`Text`, whose rendering is a pure function of their plain text, spans, and flags.
#: The TUI recomposes the whole frame on every keystroke and on the idle tick, and the
#: list-family screens rasterize row by row through :func:`render_to_ansi`; between two
#: paints almost every row is byte-identical, so this cache turns the per-row console
#: render (~0.15 ms each) into a dict hit. Tables, panels and groups have no cheap content
#: key and skip the cache (their callers memoize at their own level where it matters).
_ANSI_CACHE: "OrderedDict[tuple, str]" = OrderedDict()
#: Entries the ANSI cache holds before evicting least-recently-used ones. Sized for a few
#: screenfuls of distinct rows (a busy list, its filtered variants, a dialog over it) at
#: well under ~2 MB; small enough that even the PicoCalc carries it without noticing.
_ANSI_CACHE_MAX = 4096
#: Generation stamp folded into every cache key. A platform switch swaps the theme and
#: colour system baked into the render consoles, so it bumps the generation instead of
#: trusting eviction — a stale entry can then never be *read*, even mid-eviction.
_CACHE_GEN = 0
@on_platform
def _bind(platform: Platform) -> None:
"""Re-bind the rasterizer's colour depth and drop stale consoles on a switch."""
global _COLOR_SYSTEM, _CACHE_GEN
_COLOR_SYSTEM = "truecolor" if platform.truecolor else "standard"
_CONSOLES.clear()
_ANSI_CACHE.clear()
_CACHE_GEN += 1
def _cache_key(renderable: RenderableType, width: int, no_wrap: bool) -> Optional[tuple]:
"""A content key for a cacheable renderable, or ``None`` when it has no cheap one.
A ``str`` *is* its own content (markup included). A :class:`Text` renders as a pure
function of its plain text, span list, base style, and wrap flags — everything the
key captures (span styles via ``str``, which :class:`~rich.style.Style` memoizes).
Anything else — a table, a group, a panel — would need a deep walk to describe, so it
reports ``None`` and renders uncached.
"""
if isinstance(renderable, str):
return ("s", renderable, width, no_wrap, _CACHE_GEN)
if isinstance(renderable, Text):
return (
"t",
renderable.plain,
tuple((s.start, s.end, str(s.style)) for s in renderable.spans),
str(renderable.style),
renderable.no_wrap,
renderable.overflow,
renderable.justify,
renderable.tab_size,
width,
no_wrap,
_CACHE_GEN,
)
return None
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=active_theme(),
width=width,
file=StringIO(),
force_terminal=True,
color_system=_COLOR_SYSTEM,
# This console is a rasterizer, not terminal output: its ANSI is re-parsed
# by prompt_toolkit, and the TUI's hues are semantics (node identity,
# recency heat), not decoration. So a NO_COLOR environment must not strip
# them here — the scripted CLI's real console (theme.build_console) is the
# one that honours NO_COLOR.
no_color=False,
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)
key = _cache_key(renderable, width, no_wrap)
if key is not None:
cached = _ANSI_CACHE.get(key)
if cached is not None:
_ANSI_CACHE.move_to_end(key)
return cached
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="")
# THE render boundary: every visible string in the TUI leaves through here, so this
# one call is what makes the PicoCalc glyph contract hold app-wide (identity on the
# regular platform). Widths were measured on the pre-fold text; the fold preserves
# cell counts (wide emoji become glyph + pad), so the layout above survives it.
out = fold_text(capture.get())
if key is not None:
_ANSI_CACHE[key] = out
if len(_ANSI_CACHE) > _ANSI_CACHE_MAX:
_ANSI_CACHE.popitem(last=False)
return out
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 query_line(query: str, width: int) -> str:
"""The live find/filter query echoed as one body line: ``/yul``, warn-styled.
THE way a find-as-you-type screen shows what has been typed. Every such screen draws
this same line above whatever the query narrows — the select list above its rows, the
path composer above its suggestions, the map above its canvas, the walk above its link
list — so the text you are typing is always in the same shape, in the same place, in
the one colour nothing else in a body uses.
The map and the walk *also* echo the query in their footer hint, with its editing keys
beside it. That line is not drawn on a platform whose footer is the F-key lane (see
:attr:`~meshterm.platforms.Platform.footer_fkeys`), which is exactly why those two
call this: without it, the query would be invisible on the device while you typed it.
They gate the call on that flag rather than drawing the line twice on the desktop.
Args:
query: The current query (callers only call this when it is non-empty).
width: Inner content width in columns.
Returns:
One rendered ANSI line.
"""
return render_to_ansi(Text(f"/{query}", style="warn"), width, no_wrap=True)
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)