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:
1 parent
38d9ffacc5
commit
0b1ec94cdb
4 files changed
+97
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user