Files
MeshTerm/meshterm/ui/tui/fastrender.py
T
Jean-Pierre MartineauandClaude Fable 5 e53e35523e Map: draw the ground off the keystroke, and stop reprojecting the world
A pan cost 0.9-2.4 s. It was never the tiles - those already load in a
thread - it was render_map, which rasterizes the vector ground on the
paint path: 900-2342 ms for a view holding ~16k features.

Two changes, in that order.

The projection was doing the same trigonometry once per vertex. Every
feature_to_dot call recomputed Viewport.origin_world - a sin/log pair -
and a 2** for values constant across the whole frame, 107k times a frame.
The mapping is affine in tile-local coordinates, so tile_transform folds
it into three numbers and _draw_tile spells the arithmetic out inline: two
multiplies and two adds per vertex, no call at all. That alone is 2.4x
(z12 2342 -> 991 ms).

The rest is genuinely per-feature Python and will not shrink to keystroke
size, so the raster moved off the paint path. A paint now returns the best
picture available immediately and a background task draws the real one:
unchanged view serves the finished frame; a view that only gained a tile
keeps the old raster, which is still correctly aligned; a view that *moved*
draws the nodes alone on the new viewport, which is a few ms, so panning
tracks the keys and the streets catch up. The title says "drawing..." while
it does.

Exactly one raster runs at a time and it is always the newest requested -
a held arrow hands us a new viewport every repaint, and starting a second
of thread-bound work per keypress would queue frames that are already
stale on arrival and slow the keystrokes this exists to protect.

On the device: pan 1205 -> 89 ms, 1156 -> 130 ms, 2449 -> 91 ms; zoom
1161 -> 142 ms; a settled view 37 ms.

Also flips MESHTERM_FASTRENDER on by default (=0 still opts out), and asks
whether a loop is running *before* building a coroutine, so a static render
no longer leaves an un-awaited one behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 01:58:08 -04:00

148 lines
7.1 KiB
Python

"""Write an already-composed frame straight to the terminal, one changed row at a time.
MeshTerm composes every frame itself: :func:`~meshterm.ui.tui.frame.compose_base` returns
a finished ANSI string, exactly one line per terminal row, already sliced to the viewport
and already folded to the platform's glyph set. prompt_toolkit's own renderer then takes
that string and does a full round trip with it — parses the ANSI back into style/text
fragments, writes every one of the ~1400 cells into a fresh grid of ``Char`` objects,
diffs that grid against the previous frame's grid, and re-emits ANSI for the cells that
moved.
That round trip is the single largest cost in a keystroke on the PicoCalc, and it is paid
in full on *every* paint — a measured 70-125 ms whether the frame changed by one row, by
all of them, or not at all (the 2 s header tick recomposes an identical frame and still
pays it). Since we already know the frame as rows of text, the same pixels can be reached
by comparing this frame's rows against the last frame's rows and rewriting only the ones
that differ: ~0.1 ms, and a smaller write besides.
The bypass is deliberately narrow. It runs only when the frame is a plain full-screen
composition — one base screen, no floating dialog and no busy overlay — because those are
laid out by prompt_toolkit's float containers rather than by us, and compositing them here
would mean re-implementing that placement. Anything else falls straight through to the
stock renderer, which stays the authority: :meth:`FastRenderer.render` is an accelerator
for the common case, never a second implementation of the whole layout engine.
Two details make the row diff safe rather than merely fast:
* **A row is rewritten whole, from column 0.** The terminal's own cursor advance re-lays
the line, so a glyph it draws narrower than we measured cannot leave the rest of the row
shifted — the same guarantee :meth:`~meshterm.ui.tui.session.TuiSession._emit` relies on,
reached here for free rather than by a scrub pass.
* **Each row is prefixed with a reset and followed by an erase-to-end-of-line**, so no
style leaks in from the row above and a row that got shorter leaves no tail behind.
"""
from __future__ import annotations
import os
from typing import Callable, Optional
from prompt_toolkit.renderer import Renderer
def enabled() -> bool:
"""Whether the direct row writer is active.
On by default — it is 2-3x on every navigation keystroke and its output is verified
byte-identical against the stock renderer by reading the console back. Set
``MESHTERM_FASTRENDER=0`` to fall back to prompt_toolkit's renderer, which is the
escape hatch if a terminal ever disagrees.
"""
return os.environ.get("MESHTERM_FASTRENDER", "1") != "0"
class FastRenderer(Renderer):
"""A :class:`~prompt_toolkit.renderer.Renderer` that short-circuits simple frames.
Args:
frame_source: Returns the composed full-screen frame as one ANSI string (rows
joined by newlines), or ``None`` when this paint is not a plain base frame
and must go through prompt_toolkit's layout instead.
"""
def __init__(self, *args, frame_source: Callable[[], Optional[str]], **kwargs) -> None:
super().__init__(*args, **kwargs)
self._frame_source = frame_source
self._prev_rows: Optional[list[str]] = None
self._prev_size: Optional[tuple[int, int]] = None
#: Paints answered here vs handed to the stock renderer — read by the bench.
self.fast_paints = 0
self.slow_paints = 0
def reset(self, _scroll: bool = False, leave_alternate_screen: bool = True) -> None:
"""Forget the last frame along with prompt_toolkit's own render state."""
self._prev_rows = None
super().reset(_scroll=_scroll, leave_alternate_screen=leave_alternate_screen)
def erase(self, leave_alternate_screen: bool = True) -> None:
"""The screen is being cleared out from under us — the next paint must be full."""
self._prev_rows = None
super().erase(leave_alternate_screen=leave_alternate_screen)
def render(self, app, layout, is_done: bool = False) -> None: # noqa: ANN001
"""Paint the frame, taking the row-diff path when this frame is a plain one."""
text = None if is_done else self._frame_source()
if text is None:
# A dialog, the busy overlay, or the closing paint: prompt_toolkit lays those
# out, so it must also own the diff — its grid is the only record of what is
# on screen, and it is about to be rebuilt from scratch anyway.
self._prev_rows = None
self.slow_paints += 1
super().render(app, layout, is_done)
return
output = self.output
if self.full_screen and not self._in_alternate_screen:
self._in_alternate_screen = True
output.enter_alternate_screen()
if not self._bracketed_paste_enabled:
output.enable_bracketed_paste()
self._bracketed_paste_enabled = True
if not self._cursor_key_mode_reset:
output.reset_cursor_key_mode()
self._cursor_key_mode_reset = True
size = output.get_size()
dims = (size.rows, size.columns)
rows = text.split("\n")[: size.rows]
# A resize invalidates both records of the screen: ours and prompt_toolkit's.
full = self._prev_rows is None or self._prev_size != dims
if full:
self._last_screen = None
output.hide_cursor()
output.reset_attributes()
output.erase_screen()
output.cursor_goto(0, 0)
write = output.write_raw
prev = self._prev_rows
last = -2
for i, row in enumerate(rows):
if not full and i < len(prev) and prev[i] == row:
continue
# Stepping down a row with CRLF returns the terminal to a true column 0
# whatever happened on the row above, and costs three bytes less than a
# cursor address; only a real jump pays for one.
write("\r\n" if i == last + 1 else f"\x1b[{i + 1};1H")
# Erase *before* drawing, never after. A row that fills the terminal exactly
# leaves the cursor in the last column with wrap pending, and an erase-to-end
# there wipes the very character just written (the row's last cell went
# missing on the PicoCalc's 53-column console). Clearing first also drops the
# tail of a row that got shorter, which is what the erase was for.
write("\x1b[0m\x1b[K")
write(row)
last = i
# Park the cursor out of the text. The app hides it, but a terminal that ignores
# that should not leave it blinking in the middle of a row.
write(f"\x1b[{len(rows)};1H")
output.flush()
self._prev_rows = rows
self._prev_size = dims
self._last_size = size
# prompt_toolkit's grid no longer describes the screen — if a later paint falls
# back to it, it must redraw everything rather than diff against a stale record.
self._last_screen = None
self.fast_paints += 1