diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index 57b8727..0918d99 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -39,17 +39,26 @@ _PIN_SETTLE_PASSES = 3 _BANNER_CROP_ROWS = 9 -def _window_start(scroll: int, total: int, viewport: int, pinned: int) -> int: +def _window_start( + scroll: int, total: int, viewport: int, pinned: int, cursor: int | None = None +) -> int: """The body line the visible window starts at, given ``pinned`` reserved top rows. Normally the scroll offset itself. Scrolled fully to the bottom, the reserved rows must not cost the *last* body lines (a chat's input row would vanish exactly when the transcript fills the screen), so the window slides down by as many rows as are pinned — the lines it drops are at the top, right under the pins, where they are stale. + + Except the ``cursor``'s, which is never stale: the slide stops at the highlighted line. + A reader walking ↑ back up a list that is still scrolled to its bottom reaches the + window's top line with the scroll yet to move, and a slide past that line hid the very + row they had just stepped onto. Where the two cannot share the window the highlight + keeps its row and the last line waits below the ``↓`` marker. """ cap = max(1, viewport - pinned) if scroll >= total - viewport and total > cap: - return min(scroll + pinned, total - cap) + start = min(scroll + pinned, total - cap) + return start if cursor is None else max(scroll, min(start, cursor)) return scroll @@ -102,14 +111,14 @@ def _visible_slice(screen: Screen, lines: list[str], viewport: int) -> tuple[lis screen.scroll = scroll if pinned: - start = _window_start(scroll, total, viewport, len(pinned)) + start = _window_start(scroll, total, viewport, len(pinned), cursor) if start != scroll: # The window slid down off the scroll offset, so the pins must describe the row # it now *starts* at — otherwise a section heading among the dropped lines would # simply vanish instead of being pinned. Re-ask, then re-settle the start against # however many rows that reserves (never unpinning: the slide depends on it). pinned = screen.sticky_rows(start) or pinned - start = _window_start(scroll, total, viewport, len(pinned)) + start = _window_start(scroll, total, viewport, len(pinned), cursor) cap = max(1, viewport - len(pinned)) visible = pinned + lines[start : start + cap] more_below = start + cap < total diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index b49fded..293c3ed 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -711,14 +711,12 @@ class SelectScreen(Screen): # One entry per body line: a finished string, or a callable that draws it when the # frame asks. Positions are exact either way, which is all the layout below reads. lines: list[str | Callable[[], str]] = [] - # A prompt (when set) sits above the list, offsetting every row below it; the cursor - # line and sticky-header indices below are shifted by exactly this many lines. - prefix = 0 + # A prompt (when set) sits above the list, offsetting every row below it. Every index + # recorded below — the cursor line, the sticky headers — is read off ``len(lines)``, + # which already counts the prompt's lines, so none of them adds the offset again. if self._prompt: - plines = render_lines(Text(self._prompt), width) - lines.extend(plines) + lines.extend(render_lines(Text(self._prompt), width)) lines.append("") - prefix = len(plines) + 1 # Record each section heading (a Separator that says it is one) as a sticky block, so # one that scrolls off is re-pinned to the top rows by the base Screen.sticky_block — # and a pinned separator (a column header) as the whole-list header pinned above it. @@ -780,9 +778,8 @@ class SelectScreen(Screen): lines.append(self._detail_drawer(detail, width)) if not choices: lines.append(render_to_ansi(Text("no matches", style="muted"), width)) - # Remember where the highlighted row landed so the session can keep it in view, - # shifted past any prompt lines drawn above the list. - self._cursor = None if cursor_at is None else cursor_at + prefix + # Remember where the highlighted row landed so the session can keep it in view. + self._cursor = cursor_at return LazyLines(lines) def _row_drawer(self, item: Choice, is_sel: bool, width: int) -> Callable[[], str]: diff --git a/tests/test_device_config.py b/tests/test_device_config.py index 2d6b7ec..a8bcdec 100644 --- a/tests/test_device_config.py +++ b/tests/test_device_config.py @@ -622,6 +622,50 @@ async def test_sparing_every_row_leaves_the_sweep_with_nothing_to_do() -> None: assert victims == [] +@pytest.mark.parametrize("platform_name", ["regular", "picocalc"]) +def test_the_purge_preview_keeps_its_cursor_in_its_box_walking_both_ways( + platform_name: str, +) -> None: + """The preview's highlight stays inside its box however far the reader walks, and back. + + A long sweep scrolls, and it is a list with a prompt over a pinned column header. Both + once let the cursor leave the window on the way back up: the prompt was counted twice in + the line the frame keeps in view, so the real row rode above the box's top edge, and at + the bottom the header's slide dropped the row the reader had just stepped onto. Walked + through the real dialog compositor on each platform, at its own readable width. + """ + from rich.text import Text + + from meshterm.core.contact_score import ContactSignals, ScoredContact + from meshterm.core.models import Contact + from meshterm.platforms import PICOCALC, REGULAR, set_platform + from meshterm.ui.purge_screen import _preview_screen + from meshterm.ui.tui.frame import compose_dialog + + platform = {"regular": REGULAR, "picocalc": PICOCALC}[platform_name] + set_platform(platform) + + def scored(index: int) -> ScoredContact: + key = f"{index:02x}" * 32 + return ScoredContact( + contact=Contact(name=f"Faraway {index}", public_key=key, key_prefix=key[:12]), + signals=ContactSignals(node=key[:12], heard_age_days=40.0 + index, packets=index), + score=float(index), + percentile=index, + ) + + screen = _preview_screen([scored(i) for i in range(40)]) + cols, rows = platform.readable_cols, 24 + # The cursor opens on Apply, at the bottom. Walk to the first contact, down to Back, + # and up again: every step is a paint the reader sees. + walk = [None] + ["up"] * 45 + ["down"] * 45 + ["up"] * 45 + for step, key in enumerate(walk): + if key is not None: + screen.handle(key) + box = Text.from_ansi(compose_dialog(screen, cols, rows)).plain + assert "❯" in box, f"step {step} ({key}): the highlight left the box\n{box}" + + async def test_deleting_one_contact_drops_it_from_the_device_and_the_store() -> None: """The single-contact delete: a red confirm, then both halves of the union forgotten. diff --git a/tests/test_tui.py b/tests/test_tui.py index 7412be0..65bd290 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -183,6 +183,20 @@ def test_select_cursor_line_tracks_selection() -> None: assert screen.cursor_line() == 2 +def test_select_cursor_line_counts_a_prompt_once() -> None: + """Under a prompt, the reported cursor line is the highlighted row's own line. + + The rows are laid out after the prompt's lines, so the index they are recorded at + already counts them. Adding the prompt's height on top reported a line further down, + and the frame kept *that* line in view: walking ↑ then carried the real highlight above + the window's top edge (the purge preview, whose prompt sits over a long list). + """ + items = [Choice(f"c{i}", i) for i in range(12)] + screen = SelectScreen("pick", items, prompt="Read the list, then pick one.", default=3) + lines = screen.render_body(40) + assert "❯ c3" in Text.from_ansi(lines[screen.cursor_line()]).plain + + def test_select_callable_title_re_renders_live() -> None: """A callable title is resolved on every repaint, so a live badge tracks state.""" unread = {"n": 0} @@ -451,6 +465,26 @@ def test_select_pinned_column_header_keeps_the_last_row_reachable() -> None: assert below is False +def test_select_walking_up_from_the_bottom_keeps_the_highlight_in_view() -> None: + """↑ from the last row to the first never leaves the highlight outside the window. + + At the bottom the window slides down under its pinned rows, so they cost stale lines at + the top rather than the last ones. Walking back up, the highlight reaches the top line + of the scroll window before the scroll has to move. A slide past that line hid the row + the reader had just stepped onto, for one step with a column header pinned and for two + with a section heading under it. + """ + for viewport in (5, 6, 7, 8): + screen = _columned_menu() + screen.handle("end") + for _ in range(len(screen._choices())): + visible, _above, _below = frame._visible_slice(screen, screen.render_body(40), viewport) + rows = [Text.from_ansi(row).plain for row in visible] + current = screen._current_choice().label + assert any(f"❯ {current}" in row for row in rows), (viewport, current, rows) + screen.handle("up") + + def test_select_resolves_a_width_aware_separator_at_the_render_width() -> None: """A callable separator title is handed the render width, so a header can fit itself.""" screen = SelectScreen(