From 82ed64ffcd487098176105e1d007fee3ff8aec40 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Thu, 16 Jul 2026 21:38:44 -0400 Subject: [PATCH] Dialogs: reflush the frame on scroll so a braille body can't leave a smear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- meshterm/ui/message_paths_screen.py | 4 ++ meshterm/ui/packet_viewer.py | 3 ++ meshterm/ui/records_screen.py | 4 ++ meshterm/ui/tui/screen.py | 8 ++++ meshterm/ui/tui/session.py | 61 ++++++++++++++++++++++++----- tests/test_tui.py | 54 +++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 10 deletions(-) diff --git a/meshterm/ui/message_paths_screen.py b/meshterm/ui/message_paths_screen.py index be03f0d..500f0da 100644 --- a/meshterm/ui/message_paths_screen.py +++ b/meshterm/ui/message_paths_screen.py @@ -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, diff --git a/meshterm/ui/packet_viewer.py b/meshterm/ui/packet_viewer.py index 192ee02..36639bc 100644 --- a/meshterm/ui/packet_viewer.py +++ b/meshterm/ui/packet_viewer.py @@ -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, diff --git a/meshterm/ui/records_screen.py b/meshterm/ui/records_screen.py index 642defe..fd0dc0e 100644 --- a/meshterm/ui/records_screen.py +++ b/meshterm/ui/records_screen.py @@ -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, diff --git a/meshterm/ui/tui/screen.py b/meshterm/ui/tui/screen.py index da8280a..546da17 100644 --- a/meshterm/ui/tui/screen.py +++ b/meshterm/ui/tui/screen.py @@ -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.""" diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 29af01b..7be62c1 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -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() diff --git a/tests/test_tui.py b/tests/test_tui.py index 03115e6..24c8cf4 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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.