Frames: an idle screen is not a reason to erase the terminal

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) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-07-29 04:23:08 -04:00
1 parent f17f7b95a1
commit a2b2c85539
2 files changed
+117 -6

No files matched your search

+55 -6
View File
@@ -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 ---------------------------------------------------------------
+62
View File
@@ -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.