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:
1 parent
583be7c4f7
commit
82ed64ffcd
6 files changed
+124
-10
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user