From a2b2c85539e7a701338cb0f17dffe513794ae643 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Wed, 29 Jul 2026 04:23:08 -0400 Subject: [PATCH] Frames: an idle screen is not a reason to erase the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Trophy case flickered about once a second, and so did every other emoji-bearing screen — the more the frame held, the more obvious it was. The app repaints on a 1 Hz timer to keep the header's pulse ticking. Every one of those repaints re-composed the same frame, and _emit read the same verdict off it: this frame carries a glyph the terminal may draw narrower than prompt_toolkit reserves for it, so drop pt's cached frame and let the next paint be a full erase_down + redraw. That upgrade is right — it is what keeps a row with an emoji in it from stranding stale cells — but it was being applied to a frame that had not changed at all. Erasing the whole terminal and rewriting it identically, once a second, is precisely a flicker. So a layer that composes byte-identically to its last frame is exempt. Nothing needs rewriting when nothing changed: pt writes no cells, so its cursor cannot have drifted, and the previous paint already left the terminal aligned. What decides is the frame, not the layer that changed: a plain dialog moving over a base row that carries an emoji is rewritten from a model of that row the terminal disagrees with, so any change upgrades the paint while a wide glyph is drawn anywhere. And a layer *leaving* is a change too — a float is hidden by dropping its window, so a closing dialog simply stops rendering, and the cells it hands back to the base would otherwise be rewritten piecemeal. The background composes first, so the paint reconciles the drawn layers there. An animating header (a charging battery, a low-battery blink, a busy pulse) does change the frame every second, and still repaints in full. If that reads as flicker too, the next step is to make the full repaint rewrite in place rather than erase first — pt only erases when the cached frame is missing entirely. Co-Authored-By: Claude Opus 5 (1M context) --- meshterm/ui/tui/session.py | 61 +++++++++++++++++++++++++++++++++---- tests/test_tui.py | 62 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 1fe4cdf..110dd3f 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -287,6 +287,12 @@ class TuiSession: # deliberately *not* on the screen stack: it hovers above every layer and is shown/ # hidden by busy_overlay, independent of whatever screens are pushed. self._overlay: Optional[BusyOverlay] = None + # What each drawn layer last composed — ``layer -> (text, carries a wide glyph)`` — so + # :meth:`_emit` can tell a frame that actually changed from one the 1 Hz refresh just + # re-rendered identically, and can ask whether anything currently on screen needs the + # full-repaint treatment. Reconciled against the layers actually drawn on every paint + # (see :meth:`_reconcile_layers`). + self._layers: dict[str, tuple[str, bool]] = {} # --- stack --------------------------------------------------------------- @@ -1116,9 +1122,10 @@ class TuiSession: """Whether any dialog floats over the background this frame.""" return bool(self._float_layers()) - def _emit(self, text: str) -> ANSI: + def _emit(self, text: str, layer: str = "base") -> ANSI: """Wrap a composed frame as prompt_toolkit :class:`ANSI`, forcing a full repaint - when it holds a glyph the terminal may draw narrower than pt reserves for it. + when it holds a glyph the terminal may draw narrower than pt reserves for it *and* + the frame has actually changed. prompt_toolkit paints differentially: it rewrites only the cells that changed since the last frame, and it steps the cursor *relative* to its own width model. That is @@ -1133,13 +1140,55 @@ class TuiSession: nothing stale survives. Frames with only width-1 glyphs keep the fast differential paint. This is checked per frame, so it covers a scroll, a resize, or a timer tick alike — wherever the glyph is (a full-frame chat, a floating dialog, the overlay). + + A frame that composed *identically* to the last one is exempt, and that is what keeps + the screen still. The app repaints on a 1 Hz timer to tick the header's pulse, so on + an emoji-bearing screen — the Trophy case, six discipline icons and a delete row — + every one of those ticks was erasing the terminal and rewriting it: a full-screen + blank-and-redraw once a second, with nothing to show for it. Nothing needs rewriting + when nothing changed — pt writes no cells, so its cursor cannot have drifted, and the + previous paint already left the terminal aligned. + + What matters is whether *any* layer changed, not whether the emoji is in the one that + did: a plain dialog moving over a base row that carries an emoji is rewritten from a + model of that row the terminal disagrees with. So a changed layer upgrades the paint + whenever anything currently drawn holds a wide glyph — and a layer that goes away is + a change too (:meth:`_reconcile_layers`). """ - if _has_wide_glyph(text): - self._invalidate_last_frame() + entry = (text, _has_wide_glyph(text)) + if self._layers.get(layer) != entry: + self._layers[layer] = entry + self._repaint_if_wide() return ANSI(text) + def _repaint_if_wide(self) -> None: + """Upgrade this paint to a full repaint if any drawn layer holds a wide glyph.""" + if any(wide for _text, wide in self._layers.values()): + self._invalidate_last_frame() + + def _reconcile_layers(self) -> None: + """Forget the layers this paint won't draw, and treat their leaving as a change. + + A float is hidden by dropping its window from the layout, so a closing dialog simply + stops calling :meth:`_emit` — nothing would otherwise notice it had gone, and the + cells it gives back to the base would be rewritten piecemeal over a row the terminal + may be drawing shifted. Called at the top of the paint, from the base layer, since + the background is composed before the floats above it. + """ + live = {f"float{i}" for i in range(len(self._float_layers()))} + if self._base_screen() is not None: + live.add("base") + if self._overlay_visible(): + live.add("overlay") + gone = [layer for layer in self._layers if layer not in live] + for layer in gone: + self._layers.pop(layer) + if gone: + self._repaint_if_wide() + def _render_base(self) -> ANSI: """Render the persistent frame around the background screen.""" + self._reconcile_layers() # the paint starts here: the background composes first cols, rows = self._size() base = self._base_screen() if base is None: @@ -1160,7 +1209,7 @@ class TuiSession: if index >= len(layers): return ANSI("") cols, rows = self._size() - return self._emit(frame.compose_dialog(layers[index], cols, rows)) + return self._emit(frame.compose_dialog(layers[index], cols, rows), f"float{index}") def _overlay_visible(self) -> bool: """Whether the busy overlay should be painted this frame. @@ -1177,7 +1226,7 @@ class TuiSession: """Render the busy overlay's skeleton card (only when :meth:`_overlay_visible`).""" if self._overlay is None: return ANSI("") - return self._emit(self._overlay.render()) + return self._emit(self._overlay.render(), "overlay") # --- input --------------------------------------------------------------- diff --git a/tests/test_tui.py b/tests/test_tui.py index dd24b1d..11f59e8 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1938,6 +1938,68 @@ def test_a_wide_glyph_frame_upgrades_to_a_full_repaint() -> None: session._emit("★ you ▲ repeater ● node ⠿ chart") assert session._app.renderer._last_screen is last + # An *unchanged* wide-glyph frame is exempt — this is the 1 Hz refresh, which re-renders + # an idle screen identically. Rewriting nothing needs no repaint, and erasing the terminal + # once a second on any emoji-bearing screen is exactly the flicker. + session = TuiSession() + last = _filled() + session._app = _fake_app(last) + session._emit("🎯 Farthest node") + assert session._app.renderer._last_screen is None + session._app.renderer._last_screen = last + session._emit("🎯 Farthest node") # the timer tick: same frame, no repaint + assert session._app.renderer._last_screen is last + session._emit("🧳 Most nodes") # a real change: back to the full repaint + assert session._app.renderer._last_screen is None + + +def test_a_changed_layer_repaints_over_a_still_wide_glyph_frame() -> None: + """Any layer changing upgrades the paint while a wide glyph is drawn *anywhere*. + + A plain dialog moving over a base row that carries an emoji is rewritten from a model of + that row the terminal disagrees with, so it is the frame as a whole — not the layer that + happened to change — that decides. A layer *leaving* counts as a change too: a closing + dialog just stops rendering, and the cells it gives back to the base would otherwise be + rewritten piecemeal. + """ + import types + + from prompt_toolkit.data_structures import Size + from prompt_toolkit.layout.screen import Screen as PtScreen + + session = TuiSession() + remembered = PtScreen() + session._app = types.SimpleNamespace( + renderer=types.SimpleNamespace(_last_screen=remembered), + output=types.SimpleNamespace(get_size=lambda: Size(rows=10, columns=60)), + invalidate=lambda: None, + ) + session._emit("🎯 the board behind", "base") # a wide glyph on the background + session._app.renderer._last_screen = remembered + session._emit("plain dialog, frame 1", "float0") # no emoji of its own… + assert session._app.renderer._last_screen is None # …but the frame carries one + + session._app.renderer._last_screen = remembered + session._emit("plain dialog, frame 1", "float0") # unchanged again → left alone + assert session._app.renderer._last_screen is remembered + + # The dialog closes: one screen on the stack means no float layer this paint, so + # reconciling drops it — and, with the emoji-bearing base still drawn, the cells it hands + # back are repainted whole rather than piecemeal. + session.push(Screen()) + session._app.renderer._last_screen = remembered + session._reconcile_layers() + assert "float0" not in session._layers + assert session._app.renderer._last_screen is None + + # With nothing wide left drawn, a layer leaving costs no repaint at all. + session._layers.clear() + session._emit("plain base", "base") + session._emit("plain dialog", "float0") + session._app.renderer._last_screen = remembered + session._reconcile_layers() + assert session._app.renderer._last_screen is remembered + def test_floating_text_prompt_is_a_popup_over_a_blank_base() -> None: """``text(floating=True)`` floats as a centered popup even on an empty stack.