A list's highlight stays in its window walking back up

Scrolled down the purge preview and walked back up, the cursor rode off
the top of the box. Two things let it, and either was enough.

A select list under a prompt reported its highlighted row a prompt's
height too far down. The row's line is read off the body as it is laid
out, which already counts the prompt, and the prompt's height was then
added on top. The frame kept that lower line in view, so walking up left
the real row above the window's top edge for as long as the walk lasted.

And at the bottom of any list with a pinned column header or section
heading, the window slides down under the pins so they cost stale lines
at its top rather than the last ones. Walking up, the highlight reaches
the window's top line before the scroll has to move, and the slide
dropped exactly that line. The slide now stops at the highlighted row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-16 20:37:11 -04:00
1 parent 38d9ffacc5
commit 0b1ec94cdb
4 files changed
+97 -13

No files matched your search

+13 -4
View File
@@ -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
+6 -9
View File
@@ -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]:
+44
View File
@@ -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.
+34
View File
@@ -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(