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>
316 lines
13 KiB
Python
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)
|