diff --git a/meshterm/ui/map_screen.py b/meshterm/ui/map_screen.py index 385d4f7..35ed6ed 100644 --- a/meshterm/ui/map_screen.py +++ b/meshterm/ui/map_screen.py @@ -130,6 +130,9 @@ class MapScreen(Screen): """A full-screen, keyboard-driven map of the mesh's located nodes over an OSM basemap.""" floating = False + #: The drawing runs right up to the panel's side borders: a picture has no text edge + #: to keep off them, so the padding columns would only be map given up. + flush = True #: Whether printable keys feed the find-as-you-type node filter. The location #: picker turns this off: there, typing has no job and a silent filter would @@ -299,17 +302,18 @@ class MapScreen(Screen): """Right-edge columns the session should force-repaint on the next paint (0 = none). A braille glyph the terminal font lacks is substituted by a *double-width* fallback, - which shoves the row and smears the panel's right padding and border. The map body - itself redraws wholesale as it pans, so those cells self-heal — but the static edge - does not change frame-to-frame, so prompt_toolkit's differential paint never rewrites - it and the smear lingers there. After a move we ask the session to force just those - two columns (right padding + border) to repaint, scrubbing the smear without the - whole-frame flicker a full repaint would cause. + which shoves the row and smears the panel's right border. The map body itself + redraws wholesale as it pans, so its cells self-heal — but the static border does + not change frame-to-frame, so prompt_toolkit's differential paint never rewrites it + and the smear lingers there. After a move we ask the session to force just that + column to repaint, scrubbing the smear without the whole-frame flicker a full + repaint would cause. (The map is :attr:`flush`, so there is no padding column + between the drawing and the border to scrub as well.) """ if not self._needs_scrub: return 0 self._needs_scrub = False - return 2 # the panel's right padding cell and its right border cell + return 1 # the panel's right border cell def set_markers(self, markers: list[MapMarker]) -> None: """Replace the plotted nodes on the open map — nodes that arrived after it opened. diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index a4afcda..0a54197 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -158,6 +158,7 @@ def _panel_box( more_below: bool, border: str, caption: str = "", + flush: bool = False, ) -> Panel: """Wrap already-sliced body lines in a titled, scroll-aware panel. @@ -179,6 +180,8 @@ def _panel_box( more_below: Whether content continues below the slice. border: Border style name (``"accent"`` for the focused screen). caption: A short muted credit for the bottom rule's right end (empty for none). + flush: Drop the one-column padding inside each side border, for a body that fills + the frame (see :attr:`~meshterm.ui.tui.screen.Screen.flush`). Returns: A Rich :class:`Panel` of exactly ``len(visible) + 2`` rows. @@ -196,10 +199,20 @@ def _panel_box( subtitle=subtitle, subtitle_align="right" if caption else "center", border_style=border, - padding=(0, 1), + padding=(0, 0 if flush else 1), ) +def panel_inset(flush: bool) -> int: + """Columns the bordered base frame takes from the body's width. + + Its two side borders, plus the padding column inside each unless the screen is + :attr:`~meshterm.ui.tui.screen.Screen.flush`. One definition, because the session sizes + a screen against this frame before the frame is drawn (``base_body_size``). + """ + return 2 if flush else 4 + + #: The last framed base composition: ``(content key, rendered lines)``. Re-parsing the #: sliced body (``Text.from_ansi``) and re-rendering it through the Panel is the priciest #: part of a repaint (~8 ms on a full frame), and the idle tick recomposes an unchanged @@ -358,7 +371,7 @@ def compose_base( # list inside itself (see :class:`~meshterm.ui.tui.screen.ListWindow`) can size # its chrome to the frame it is about to be sliced into. base.note_viewport(viewport) - body_lines = base.render_body(cols - 4) + body_lines = base.render_body(cols - panel_inset(base.flush)) visible, more_above, more_below = _visible_slice(base, body_lines, viewport) # The panel wrap is a pure function of what's between its borders: memoize it so # the repaints that change nothing below the header (the 1 Hz tick) skip the @@ -372,11 +385,12 @@ def compose_base( caption = base.bottom_caption # Rich sets the padded title into the rule's inner ``cols - 4`` cells. title = fitted_title(base, cols - 4 - 2) - key = (cols, rows, title, caption, footer_hint, more_above, more_below, *visible) + flush = base.flush + key = (cols, rows, title, caption, flush, footer_hint, more_above, more_below, *visible) if footer_lane is None and _BASE_BOX_CACHE is not None and _BASE_BOX_CACHE[0] == key: body = _BASE_BOX_CACHE[1] else: - panel = _panel_box(title, visible, more_above, more_below, "accent", caption) + panel = _panel_box(title, visible, more_above, more_below, "accent", caption, flush) body = render_lines(Group(panel, footer_row()), cols) if footer_lane is None: _BASE_BOX_CACHE = (key, body) diff --git a/meshterm/ui/tui/screen.py b/meshterm/ui/tui/screen.py index 32e892e..f1b222d 100644 --- a/meshterm/ui/tui/screen.py +++ b/meshterm/ui/tui/screen.py @@ -106,12 +106,19 @@ class Screen: screen may change it as its state changes. Drawn only where the platform's frame *has* a bottom rule — the borderless one has none, and a screen wanting a mark there puts it in its own body instead. + flush: Whether, as the base screen in a bordered frame, the body runs right up to + the panel's side borders instead of sitting one blank column in from each. The + padding is air between a border and a row of *text*; a picture that fills the + frame (the map) has no text edge to keep off the border, and those two columns + are drawing it would otherwise give up for nothing (JP, 2026-10-01). Moot where + the platform's frame has no border — the body is the full width there already. """ title: str = "" short_title: str = "" footer_hint: str = "Esc back" bottom_caption: str = "" + flush: bool = False floating: bool = True modal: bool = False grow_only: bool = False diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 9afd856..f649fec 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -1813,7 +1813,9 @@ class TuiSession: without waiting a repaint to learn its height. *Both* of that function's branches: a borderless platform swaps the panel's two border rows and four padding columns for a single title-bar row, so a screen sizing itself against the bordered math there - would leave a row of the frame it was handed permanently blank. + would leave a row of the frame it was handed permanently blank. The width is the + current base screen's, which a :attr:`~meshterm.ui.tui.screen.Screen.flush` one + widens by the two padding columns it does without. Returns: The inner content width and the body viewport height, both in character cells. @@ -1823,8 +1825,10 @@ class TuiSession: cols, rows = self._size() header_h = len(render_lines(self._header(cols), cols, no_wrap=True)) if get_platform().frame_border: - # minus footer(1) and panel border(2); the border eats 4 columns too - return cols - 4, max(1, rows - header_h - 1 - 2) + base = self._base_screen() + inset = frame.panel_inset(base is not None and base.flush) + # minus footer(1) and panel border(2) + return cols - inset, max(1, rows - header_h - 1 - 2) return cols, max(1, rows - header_h - 1 - 1) # minus footer(1) and title bar(1) def _base_index(self) -> int: diff --git a/tests/test_map.py b/tests/test_map.py index 997e4cc..5198103 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -1891,7 +1891,7 @@ def test_map_screen_scrubs_right_edge_after_move() -> None: braille = MapScreen(_StubSession(80, 24), markers, _StubSource(), 14) braille.render_body(80) braille.handle("right") # a move flags the edge for a scrub - assert braille.consume_edge_scrub() == 2 # right padding + border columns + assert braille.consume_edge_scrub() == 1 # the border column: the map is flush assert braille.consume_edge_scrub() == 0 # consumed — not repeated without another move diff --git a/tests/test_tui.py b/tests/test_tui.py index 675c5dd..15c790f 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -743,6 +743,22 @@ def test_compose_base_fills_exactly_terminal_height() -> None: assert out.count("\n") + 1 == rows +def test_a_flush_screen_draws_up_to_the_side_borders() -> None: + """A ``flush`` base gets the two padding columns back; an ordinary one keeps its air.""" + + class Fill(Screen): + floating = False + + def render_body(self, width: int) -> list[str]: + return ["#" * width] * 3 + + for flush, row in ((True, "│" + "#" * 78 + "│"), (False, "│ " + "#" * 76 + " │")): + screen = Fill() + screen.flush = flush + lines = [_plain(ln) for ln in frame.compose_base(Text("h"), screen, "", 80, 12).split("\n")] + assert row in lines, (flush, lines) + + def test_compose_dialog_is_bounded() -> None: """A dialog for a huge renderable never exceeds the terminal height.""" tall = Text("\n".join(f"row {i}" for i in range(200)))