Dialogs: reflush the frame on scroll so a braille body can't leave a smear

A dialog that draws a route graph (message paths, the trophy case, the packet
viewer) paints braille and node-type marks. On a terminal whose font lacks a
glyph, the double-width fallback shoves that row right and smears a static cell
— a box border — that prompt_toolkit's differential renderer never rewrites (it
only redraws each row up to its new content and only erases a shortened tail),
so the fragment lingers once a scroll slides the offending row away.

The map fixes its own case by scrubbing two right-edge columns after a pan, but
that only works for a full-frame panel whose border sits at the terminal edge;
a route-graph dialog is a centred box (or, on an empty stack, a full-frame base)
whose smear can land anywhere, so edge geometry can't target it reliably.

Instead: a screen opts in with `smears = True`, and on a scroll key the session
reflushes — fills pt's remembered frame with a sentinel so the next diff rewrites
every cell *in place* and erase-to-end-of-lines each tail, scrubbing the leftover
wherever it sits. Because the cached frame is kept (not dropped), pt overwrites
rather than erasing first, so there's no blank-flash flicker. Plain dialogs keep
the efficient differential paint untouched; the map/atlas keep their own edge
scrub. Functionality is unchanged — only the smear is cleaned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-16 21:38:44 -04:00
1 parent 583be7c4f7
commit 82ed64ffcd
6 files changed
+124 -10

No files matched your search

+4
View File
@@ -51,6 +51,10 @@ _EDGE_UNUSED = (110, 110, 110)
class MessagePathsScreen(Screen):
"""A floating dialog: one message's arrivals as a path graph over a row list."""
#: The path graph is braille, which the terminal may render wider than modelled, so a
#: scroll reflushes the box to scrub any smeared border cell (see :attr:`Screen.smears`).
smears = True
def __init__(
self,
message: ChatMessage,
+3
View File
@@ -241,6 +241,9 @@ class PacketViewer(Screen):
"""
grow_only = True
#: A relayed packet's route graph is braille, which the terminal may render wider than
#: modelled — so a paging/scroll reflushes the box to scrub any smeared border cell.
smears = True
def __init__(
self,
+4
View File
@@ -120,6 +120,10 @@ class RecordDialog(Screen):
removes this one record behind a red Cancel/Delete data-loss confirm.
"""
#: The braille mini-map and route graph can render wider than modelled, so a scroll
#: reflushes the box to scrub any smeared border cell (see :attr:`Screen.smears`).
smears = True
def __init__(
self,
record: DiscoveredPath,
+8
View File
@@ -48,6 +48,13 @@ class Screen:
base screen. Ignored while ``chrome`` is ``True``.
footnote: A short line drawn muted and centered *below* a chromeless base screen's
box (e.g. a copyright notice). Ignored while ``chrome`` is ``True``.
smears: Whether this screen paints braille or other glyphs the terminal may render
wider than prompt_toolkit models (a route graph's fan lanes, the node-type
marks). Such a glyph's double-width fallback can smear a static cell — a dialog
border — that the differential renderer never rewrites, so the session reflushes
the frame after a scroll (see :meth:`~meshterm.ui.tui.session.TuiSession._reflush_frame`)
to scrub the leftover. A full-frame braille screen that already cleans its own
edge (the map, via :meth:`consume_edge_scrub`) leaves this ``False``.
"""
title: str = ""
@@ -57,6 +64,7 @@ class Screen:
chrome: bool = True
banner: Optional[Sequence[str]] = None
footnote: Optional[str] = None
smears: bool = False
def __init__(self) -> None:
"""Initialize scroll state and the (later-assigned) result future."""
+51 -10
View File
@@ -90,6 +90,16 @@ _RECLAIM_LAST_COLUMN = os.environ.get("MESHTERM_FULL_WIDTH", "1") != "0"
#: real nesting — a tool's list, an item's detail popup, and a confirm over that is only three.
_MAX_DIALOG_LAYERS = 8
#: Scroll/pan actions after which a :attr:`~meshterm.ui.tui.screen.Screen.smears` screen
#: reflushes the frame (see :meth:`TuiSession._reflush_frame`) — every key that can move
#: wide-glyph content, so a leftover fragment is scrubbed the moment its row shifts.
_REFLUSH_ACTIONS = frozenset({
"up", "down", "pageup", "pagedown", "home", "end",
"ctrl_home", "ctrl_end", "ctrl_pageup", "ctrl_pagedown",
"left", "right", "ctrl_left", "ctrl_right",
"shift_up", "shift_down", "shift_left", "shift_right", "space",
})
class _WidthExtendedOutput:
"""A prompt_toolkit ``Output`` proxy that reports one extra terminal column.
@@ -251,14 +261,15 @@ class TuiSession:
if renderer is not None and hasattr(renderer, "_last_screen"):
renderer._last_screen = None
def _scrub_right_columns(self, count: int) -> None:
"""Force prompt_toolkit to repaint the rightmost ``count`` columns on the next diff.
def _scrub_columns(self, start: int, stop: int) -> None:
"""Force prompt_toolkit to repaint terminal columns ``[start, stop)`` on the next diff.
Overwrites those cells in pt's remembered last frame with a sentinel that can't equal
any real content, so the differential renderer treats them as changed and redraws
them — scrubbing a double-width fallback glyph that smeared over the panel's right
edge, without the whole-frame flicker of dropping the entire cached frame. Touches a
pt internal, so it fails soft if the structure ever moves.
them — scrubbing a double-width fallback glyph that smeared over a *static* edge (the
map's panel border, a floating dialog's), without the whole-frame flicker of dropping
the entire cached frame. Touches a pt internal, so it fails soft if the structure ever
moves.
"""
renderer = getattr(self._app, "renderer", None)
last = getattr(renderer, "_last_screen", None)
@@ -268,14 +279,34 @@ class TuiSession:
from prompt_toolkit.layout.screen import Char
buffer = last.data_buffer
cols, _ = self._size()
sentinel = Char("￿") # a non-character; never equals real cell content
for x in range(max(0, cols - count), cols):
for x in range(max(0, start), stop):
for row in list(buffer.keys()):
buffer[row][x] = sentinel
except Exception: # noqa: BLE001 - a cosmetic scrub must never break rendering
pass
def _scrub_right_columns(self, count: int) -> None:
"""Scrub the terminal's rightmost ``count`` columns — a full-frame panel's edge."""
cols, _ = self._size()
self._scrub_columns(cols - count, cols)
def _reflush_frame(self) -> None:
"""Force the next paint to rewrite every cell in place — no erase, no flicker.
Fills pt's remembered last frame with a sentinel so the differential renderer reads
every cell as changed and redraws it (and erase-to-end-of-lines each row's tail),
overwriting a double-width fallback glyph's smeared halves wherever they landed. A
screen that draws braille or other glyphs the terminal may paint wider than pt models
(a route-graph dialog — see :attr:`~meshterm.ui.tui.screen.Screen.smears`) reflushes
after a scroll, the way the map scrubs its edge after a pan. Unlike
:meth:`request_full_repaint` this keeps the cached frame, so pt overwrites in place
rather than erasing first — no blank flash. Scrubbing the whole frame sidesteps the
centred box's geometry entirely: the smear is cleaned wherever it sits.
"""
cols, _ = self._size()
self._scrub_columns(0, cols)
# --- async prompt helpers ------------------------------------------------
async def run_screen(self, screen: Screen) -> Any:
@@ -1057,9 +1088,19 @@ class TuiSession:
return kb
def _dispatch(self, action: str, data: str = "") -> None:
"""Forward an action to the top screen and repaint."""
if self.top is not None:
self.top.handle(action, data)
"""Forward an action to the top screen and repaint.
A screen that paints braille or other glyphs the terminal may render wider than
prompt_toolkit models (a route-graph dialog, :attr:`~meshterm.ui.tui.screen.Screen.
smears`) can leave a smeared fragment on a static cell — a dialog border — that the
differential renderer never rewrites. After a scroll moves the offending row away,
reflush the frame so the leftover is scrubbed (see :meth:`_reflush_frame`).
"""
top = self.top
if top is not None:
top.handle(action, data)
if action in _REFLUSH_ACTIONS and getattr(top, "smears", False):
self._reflush_frame()
self.invalidate()
+54
View File
@@ -1480,6 +1480,60 @@ def test_session_background_is_the_topmost_full_frame_screen() -> None:
assert session._float_layers() == [dialog]
def test_scrolling_a_smear_prone_screen_reflushes_the_whole_frame() -> None:
"""A braille screen force-rewrites every cell on scroll, so a wide-glyph smear on any
static cell (a dialog border) is scrubbed — where a plain screen keeps the fast diff."""
import types
from prompt_toolkit.data_structures import Size
from prompt_toolkit.layout.screen import Char
from prompt_toolkit.layout.screen import Screen as PtScreen
def _fake_app(last: PtScreen) -> types.SimpleNamespace:
return types.SimpleNamespace(
renderer=types.SimpleNamespace(_last_screen=last),
output=types.SimpleNamespace(get_size=lambda: Size(rows=10, columns=60)),
invalidate=lambda: None,
)
class _Braille(ScrollScreen):
smears = True
def _filled() -> PtScreen:
screen = PtScreen()
for row in range(10):
for x in range(60):
screen.data_buffer[row][x] = Char("A")
return screen
# A smear-prone screen: a scroll fills the remembered frame with the scrub sentinel, so
# the next diff rewrites every cell in place (no cell is left for the smear to hide in).
session = TuiSession()
last = _filled()
session._app = _fake_app(last)
session.push(_Braille(Text("x")))
session._dispatch("down")
assert all(last.data_buffer[r][x].char == "￿" for r in range(10) for x in range(60))
# A plain screen keeps the efficient differential paint — the frame is left untouched.
session = TuiSession()
last = _filled()
session._app = _fake_app(last)
session.push(ScrollScreen(Text("x"))) # smears defaults False
session._dispatch("down")
assert last.data_buffer[0][0].char == "A"
def test_smear_prone_route_graph_dialogs_opt_into_the_reflush() -> None:
"""The braille route-graph dialogs declare ``smears`` so a scroll scrubs their borders."""
from meshterm.ui.message_paths_screen import MessagePathsScreen
from meshterm.ui.packet_viewer import PacketViewer
from meshterm.ui.records_screen import RecordDialog
assert PacketViewer.smears and MessagePathsScreen.smears and RecordDialog.smears
assert Screen.smears is False # the default: plain screens keep the fast diff
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.