Files
Jean-Pierre MartineauandClaude Opus 5.5 796ff94dad Our star is white
The star of our node had the hue of our key, set from the self-info at run
time. It is now the white of "you" on each surface: the map, the routes,
the lists, the legends, and the chat. The run-time colour, the device-state
hook, and the style generation of the render cache are removed. A faded
star keeps its grey.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 00:18:59 -04:00

514 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""The only bridge from Rich to prompt_toolkit.
The TUI keeps its rendering in Rich (all the existing tables, panels, and text widgets are
used again exactly as they are). It lets prompt_toolkit own the terminal, the input, and
the resize events. This module is the seam between the two. It renders any Rich
renderable to an ANSI string at a chosen width. Then prompt_toolkit draws that string
through :class:`prompt_toolkit.formatted_text.ANSI`.
Each paint renders again at the current width. Thus the content wraps to the new width
automatically when the terminal changes size.
"""
from __future__ import annotations
from collections import OrderedDict
from collections.abc import Sequence
from io import StringIO
from rich.cells import cell_len
from rich.console import Console, RenderableType
from rich.text import Span, Text
from ...platforms import Platform, on_platform
from ..pathline import PATH_INK
from ..theme import active_theme, fold_text, is_identity_style
#: A cache of one headless render console for each width. Consoles are cheap, but a paint
#: occurs at each key press and at each tick of the live-monitor timer. Thus the cache
#: prevents work that is not necessary. The cache key is the width, because the width of a
#: console is fixed at construction. The cache is cleared each time that the platform
#: changes, because the theme and the colour system are fixed at construction.
_CONSOLES: dict[int, Console] = {}
#: The colour depth that the rasterizer writes at, bound for each platform. On the
#: 16-slot console it is ``"standard"``. The ``color(N)`` styles of the theme go through
#: as exact slot indices. Rich downsamples any stray truecolour (a node hue, a map
#: feature) to the nearest standard slot. The palette keeps each slot aligned with its hue
#: on purpose (refer to ``theme._VT_SLOTS``), so even a stray colour lands in its own
#: colour family.
_COLOR_SYSTEM = "truecolor"
#: A cache of the ANSI for each (content, width), for the renderables that can be
#: described: a ``str`` or a :class:`Text`, whose rendering is a pure function of their
#: plain text, spans, and flags. The TUI composes the full frame again at each key press
#: and at the idle tick. The screens of the list family rasterize row by row through
#: :func:`render_to_ansi`. Between two paints, almost every row is byte-identical. Thus
#: this cache changes the console render of each row (approximately 0.15 ms each) into a
#: dict hit. Tables, panels, and groups have no cheap content key and skip the cache.
#: (Their callers cache at their own level where it is important.)
_ANSI_CACHE: OrderedDict[tuple, str] = OrderedDict()
#: The number of entries that the ANSI cache holds before it evicts the least recently
#: used entries. The size is for a few screens of different rows (a busy list, its
#: filtered variants, a dialog over it), at much less than 2 MB. It is small enough that
#: even the PicoCalc holds it with no problem.
_ANSI_CACHE_MAX = 4096
#: The generation stamp in each cache key. A platform switch changes the theme and the
#: colour system that are fixed in the render consoles. Thus the switch increments the
#: generation, and does not trust eviction. Then a stale entry can never be read, even in
#: the middle of an eviction.
_CACHE_GEN = 0
@on_platform
def _bind(platform: Platform) -> None:
"""Bind the colour depth of the rasterizer again, and remove 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) -> tuple | None:
"""A content key for a renderable that can be cached, or ``None`` if it has no cheap key.
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, and the key holds
all of these (the span styles through ``str``, which :class:`~rich.style.Style`
caches). To describe any other renderable (a table, a group, a panel), a deep walk is
necessary. Thus it reports ``None`` and renders without the cache.
"""
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 writes ANSI at the given width.
Args:
width: The target render width, in cells.
Returns:
A cached :class:`rich.console.Console` that writes 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. prompt_toolkit parses its
# ANSI again, and the hues of the TUI are semantics (node identity, recency
# heat), not decoration. Thus a NO_COLOR environment must not remove them
# here. The real console of the scripted CLI (theme.build_console) is the
# console that obeys NO_COLOR.
no_color=False,
highlight=False,
soft_wrap=False,
)
_CONSOLES[width] = console
return console
def _whiten_identities(renderable: RenderableType) -> RenderableType:
"""On the highlight, the name of a node is drawn in the highlight's white, not its hue.
The name of a node is always coloured by its key, so a column of names reads as a
column of identities. But the row that ``❯`` points to answers a different question,
and a keyed hue on it competes with the highlight that says "this one". Thus the row
that declares itself the highlight has its identity spans folded to that same white,
and the row reads as one thing. (Its base style is ``cursor``. That is how each list,
and each screen that draws its own rows, marks the highlight.)
Only :func:`~meshterm.ui.theme.is_identity_style` hues fold. Nothing else on the row
changes: the heat of the heard age, an SNR reading, a red badge, the chip fills of a
path line, the grey of a node that no key could place. None of these is the identity
that the highlight stands in for.
**A path line keeps its hues**, at any position on the row. There the hue is not
decoration on a name. It tells one hop from the next, and a route graph drawn above
the row is cross-referenced by it. The labels of that graph are a marker and one byte
of hash, and the colour is what says which node. Earlier, a picked route kept the
colours of its hops in chip form (the fills were never in this vocabulary), but lost
them in arrow form (JP, 2026-08-30). Arrow form is each path line on the PicoCalc, and
on each terminal without the powerline glyphs.
The widget stamps its own extent with :data:`~meshterm.ui.pathline.PATH_INK`. The
stamp arrives here as a span over the hops (``Text.append_text`` keeps the base style
of a child as one span). Thus the fold skips the route by its name, and no screen has
to remember to ask.
This is done here because here a row is a row: one boundary that the rows of each
screen already go through, instead of a rule that each screen has to remember. Callers
give read-only :class:`Text` (the ANSI cache uses their content as its key), so the
change is done on a copy.
Args:
renderable: The renderable that is about to be rendered.
Returns:
The renderable, or a copy of a highlight row with its name hues changed to white.
"""
if not isinstance(renderable, Text) or str(renderable.style) != "cursor":
return renderable
spans = renderable.spans
if not any(is_identity_style(str(span.style)) for span in spans):
return renderable
routes = [(span.start, span.end) for span in spans if str(span.style) == PATH_INK]
out = renderable.copy()
out.spans = [
Span(span.start, span.end, "cursor")
if is_identity_style(str(span.style)) and not _within(span, routes)
else span
for span in spans
]
return out
def _within(span: Span, runs: list[tuple[int, int]]) -> bool:
"""Whether ``span`` is inside one of ``runs``: a hop inside a marked path line."""
return any(start <= span.start and span.end <= end for start, end in runs)
def render_to_ansi(renderable: RenderableType, width: int, *, no_wrap: bool = False) -> str:
"""Render a Rich renderable to an ANSI string at ``width`` cells.
Args:
renderable: Any Rich renderable (table, panel, text, group, markup string).
width: The target width in cells. The content wraps or pads to it.
no_wrap: When ``True``, keep the content on a single line: show what fits, and
crop the overflow (with an ellipsis). Do not wrap it onto more lines.
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
renderable = _whiten_identities(renderable)
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 only render boundary: each visible string in the TUI goes out through here. Thus
# this one call makes the PicoCalc glyph contract true in all of the app (on the
# regular platform, the fold changes nothing). The widths were measured on the text
# before the fold. The fold keeps the cell counts (a wide emoji becomes a glyph and a
# pad), so the layout above stays correct after 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`` cells.
Each line that is returned is an ANSI string with its own styles. Thus the caller can
slice a vertical range of lines (to scroll), and no escape sequence is split.
Args:
renderable: Any Rich renderable.
width: The target width in cells.
no_wrap: When ``True``, keep the content to a single cropped line (refer to
:func:`render_to_ansi`).
Returns:
The rendered lines, with no newlines. A trailing empty line (from the last
newline) is removed, so that the line count matches the visible lines.
"""
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 or filter query, shown as one body line: ``/hub``, in the warn style.
The only way that a find-as-you-type screen shows what the user typed. Each such
screen draws this same line above the content that the query narrows: the select list
above its rows, the path composer above its suggestions, the map above its canvas, and
the walk above its link list. Thus the text that the user types always has the same
shape, in the same place, in the one colour that nothing else in a body uses.
The map and the walk also show the query in their footer hint, with the keys that edit
it next to it. That line is not drawn on a platform whose footer is the F-key lane
(refer to :attr:`~meshterm.platforms.Platform.footer_fkeys`). That is why those two
screens call this function: without it, the query is not visible on the handheld
while the user types it. They call it only when that flag is set, so that the desktop
does not draw the line two times.
Args:
query: The current query (callers call this function only when the query is not
empty).
width: The inner content width, in cells.
Returns:
One rendered ANSI line.
"""
return render_to_ansi(query_text(query), width, no_wrap=True)
def query_text(query: str) -> Text:
"""The same echo as a styled run, for a surface that does the layout of the row itself.
:func:`query_line` is the full line and the usual call. This function is the run in
that line, for a place where something else shares the row. The map is the only such
caller. Its credit is pinned to the right end of the row (refer to
:mod:`meshterm.ui.attribution`). Thus the query must arrive in a form that can be
cropped, not already rendered to the full width. Both functions use this one
constructor, so the shape and the ``warn`` colour cannot become different.
Args:
query: The current query (callers call this function only when the query is not
empty).
Returns:
The echo, ``warn`` and ``no_wrap``.
"""
return Text(f"/{query}", style="warn", no_wrap=True)
def right_aligned_tail(
body: Text, tail: Text, width: int, *, whole: Sequence[tuple[int, int]] = ()
) -> Text:
"""Lay out ``body`` with ``tail`` pinned to the right edge of its last line.
``body`` wraps at ``width`` as usual. ``tail`` is a short status, for example the byte
counter of the chat compose bar. It is right-aligned on the last wrapped line of
``body`` when it still fits there (after at least one blank cell). If not, it goes on
a new line of its own, also right-aligned. Thus the tail reads as a steady gauge in
the corner, and does not follow the cursor and wrap with the text.
Args:
body: The leading content that can wrap (for example, the input line, with the
cursor block).
tail: The short run to pin to the right edge.
width: The total render width, in cells.
whole: ``(start, end)`` offsets into ``body.plain`` of the runs that the wrap must
not break at a space inside them: the chip of a mention in the compose line.
A run moves to the next line as one word (refer to :func:`render_hanging`).
Returns:
A single :class:`Text` with embedded newlines, ready for :func:`render_lines`.
"""
width = max(1, width)
console = _console(width)
held, stand_in = _held(body, whole)
lines = list(held.wrap(console, width)) or [Text("")]
if stand_in:
for line in lines:
line.plain = line.plain.replace(stand_in, " ")
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 range ``[start, start + width)``.
The primitive for horizontal scroll: a row that is wider than its lane shows ``width``
cells at a time, moved ``start`` cells in, with its styles kept. The boundaries are
measured in cells. Thus a double-width glyph that crosses either edge is removed (and
replaced by a pad space on the left), and is not shown as a half.
Args:
text: The styled line to crop (treated as a single line).
start: The cells to skip from the left (clamped at 0).
width: The cells that the range spans.
Returns:
A new :class:`Text` that is at most ``width`` cells wide, with ``no_wrap`` set so
that the caller can put it directly 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. If not, remove it, and pad for the half that sticks out.
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,
whole: Sequence[tuple[int, int]] = (),
gutter: int = 0,
) -> list[str]:
"""Render ``prefix + body`` at ``width``, and wrap the body with a hanging indent.
The first visual line has ``prefix``, then the body. Each wrapped continuation line is
padded by ``indent`` cells, so that it aligns under the body and does not go back to
column zero. The chat transcript uses this function, so that a wrapped message aligns
with its own first line and not with the timestamp gutter.
``whole`` names runs of the body that must not be cut where the screen can hold them.
A URL is such a run: a terminal can open it, and a user can copy it, only while it is
on one line. A chip is another: a mention that breaks between its notch and its name
reads as two things. A run that fits the hanging lane moves to the next line as one
word. The wrap breaks only at a space, and a space inside a run does not count for it
(refer to :func:`_held`), because a chip has its pads and a name can have spaces. A
run that is wider than the lane, but not wider than the screen, steps out of the block
onto a line of its own. It starts at ``gutter``, or as far to the right of the gutter
as still fits. After it, the body continues under its indent, unless the text that
follows is short enough to finish the line of the run. Only a run wider than the full
screen is folded, as before, because no line can hold it.
Args:
prefix: The leading run (for example, a pointer and a timestamp), shown one time,
on the first line.
body: The message text that can wrap. Its styles (mentions, glyphs) are kept.
width: The total render width, in cells.
indent: The cells by which to indent continuation lines (usually
``prefix.cell_len``).
whole: ``(start, end)`` offsets into ``body.plain`` of the runs to keep on one
line.
gutter: The column at which a run that is too wide for the lane starts, when it
can.
Returns:
The rendered lines, with no newlines.
"""
width = max(1, width)
console = _console(width)
avail = max(1, width - indent)
plain = body.plain
# The runs that must step out: wider than the lane, but not wider than the screen.
out = [
(start, end) for start, end in sorted(whole) if avail < cell_len(plain[start:end]) <= width
]
rows: list[tuple[int, Text]] = [] # (left pad, line). The pad of the first row is the prefix.
held, stand_in = _held(body, whole)
def hang(start: int, end: int) -> None:
"""Wrap ``body[start:end]`` under the indent, and trim the seams next to a run."""
if out:
while start < end and held.plain[start].isspace() and start:
start += 1
while end > start and held.plain[end - 1].isspace():
end -= 1
lines = list(held[start:end].wrap(console, avail)) if end > start else []
if stand_in:
for line in lines:
line.plain = line.plain.replace(stand_in, " ")
if not rows and not lines:
lines = [Text("")] # the line of the prefix, also when the body starts with a run
rows.extend((indent, line) for line in lines)
pos = 0
for i, (start, end) in enumerate(out):
if start < pos:
continue
hang(pos, start)
length = cell_len(plain[start:end])
pad = gutter if length + gutter <= width else width - length
line = body[start:end]
# The text up to the next run joins this line when all of it fits. A delivery mark
# or an SNR alone on a line of its own reads as a stray.
upto = out[i + 1][0] if i + 1 < len(out) else len(plain)
tail = plain[end:upto].rstrip()
if tail and "\n" not in tail and pad + length + cell_len(tail) <= width:
line.append_text(body[end : end + len(tail)])
end = upto
rows.append((pad, line))
pos = end
if pos < len(plain) or not rows:
hang(pos, len(plain))
combined = Text()
for i, (pad, line) in enumerate(rows):
if i:
combined.append("\n")
combined.append_text(prefix if i == 0 else Text(" " * pad))
combined.append_text(line)
return render_lines(combined, width)
def _held(body: Text, runs: Sequence[tuple[int, int]]) -> tuple[Text, str]:
"""A copy of ``body`` in which no run has a space, and the character that stands in for one.
The wrap of Rich breaks a line only at whitespace. Thus a run that has no space in it
moves to the next line whole, and a run that has spaces can break at each of them. In
the copy, each space inside a run is a character that the body does not use, from the
private use area. It has one cell, the same as the space, so the wrap measures the same
widths. The caller wraps the copy, then puts the spaces back on each line.
Args:
body: The text that is about to wrap.
runs: ``(start, end)`` offsets into ``body.plain`` of the runs to keep together.
Returns:
``(copy, stand_in)``. When no run has a space, this is the body itself and ``""``.
"""
plain = body.plain
spaced = [(start, end) for start, end in runs if " " in plain[start:end]]
if not spaced:
return body, ""
stand_in = next(c for c in map(chr, range(0xE000, 0xF900)) if c not in plain)
chars = list(plain)
for start, end in spaced:
chars[start:end] = [stand_in if c == " " else c for c in chars[start:end]]
held = body.copy()
held.plain = "".join(chars) # the same length, so each span stays where it was
return held, stand_in