Edge scroll: an arrow past the last row a highlight can reach scrolls the page

On a page taller than the screen, whatever sat above the first selectable
row or below the last could scroll away for good: the frame always pulled
the highlight into view, and nothing up there could take it. Now, with the
highlight on the first row, up scrolls the page a line instead, and on the
last, down does, until the page's real top or bottom is in view. Home and
End take the page to its ends with the highlight.

The highlight may leave the screen meanwhile, on purpose. The snap-back
brings it home: while it is out of view, the other arrow, Enter or Delete
only bring it back, and do nothing else, so nothing runs that cannot be
seen. Any other key ends the edge scroll.

It lives in Screen.edge_scroll, asked before a screen's own handle; select
and reorder lists, the node page, chat, message paths, the path composer,
records and the TX sweep answer cursor_at_edge. CLAUDE.md names the rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-04 01:34:01 -04:00
1 parent acc1341883
commit c7b3998a62
13 files changed
+316 -10

No files matched your search

+13
View File
@@ -215,6 +215,19 @@ deliberately, one at a time, and say why in the code.
with no reverse of its own**, which cycles or it dead-ends — Tab on a button row, and
the picocalc F-lane's single-chip advancers (`Sort →`, the node page's tab chip, the
Time Machine's window chip). Those state it in the code.
- **Edge scroll** — what a clamped arrow does instead. With the highlight on the first
selectable row, ↑ scrolls the page up a line, and on the last, ↓ scrolls it down, until
the page's real top or bottom is in view: whatever sits above the first row or below
the last (a node's vitals, a list's lead-in, a closing note) can always be reached.
Home and End go to the page's very top and bottom with the highlight. The frame stops
following the highlight while a page is edge-scrolled, so it may leave the screen —
deliberately. **The snap-back** brings it home: while it is out of view, a key that
would move it or act on it (the other arrow, Enter, Delete) only brings it back into
view, jumping if it must, and does nothing else — nothing runs that can't be seen. Any
other key ends the edge scroll. It lives in `Screen.edge_scroll`, which the session asks
before `handle`; a screen opts in by answering `cursor_at_edge` (`Screen.row_at_edge`
for an index over rows), and one whose ↑↓ move no highlight (a prompt's history
recall) leaves it `None`.
- Grouped-list section headings use `section_heading("Label")` → `── Label ──` in the
bold `heading` grey, never the frame's accent: a rule in the border's colour reads as
part of the frame. A chat's day divider wears the same dress. That is also what makes a heading *sticky* (it pins to the top row while its section
+11
View File
@@ -95,6 +95,8 @@ class ChatScreen(Screen):
"""
floating = False
#: Home and End move the compose line's caret here, so edge scroll leaves them alone.
home_end_jumps = False
@property
def fkey_lane(self):
@@ -358,6 +360,15 @@ class ChatScreen(Screen):
"""Keep the picked reply target in view; otherwise free scroll (managed by stick)."""
return self._selected_line
def cursor_at_edge(self, step: int) -> bool:
"""Edge scroll's gate: the oldest message picked, going up; never going down.
Above the oldest message sit its sender's line and the day it was sent, which ↑ on
it scrolls into view. Below the newest there is only the compose line, always shown,
and ↓ past the newest already leaves the pick for it.
"""
return step < 0 and (not self._messages or self._selected == 0)
# --- outgoing byte budget ------------------------------------------------
def _byte_limit(self) -> int:
+4
View File
@@ -267,6 +267,10 @@ class MessagePathsScreen(Screen):
"""
return self._cursor
def cursor_at_edge(self, step: int) -> bool:
"""Edge scroll's gate: the first arrival going up, the last going down."""
return self.row_at_edge(self._index, len(self._arrivals), step)
# --- rendering -------------------------------------------------------------
def render_body(self, width: int) -> list[str]:
+9
View File
@@ -694,6 +694,15 @@ class NodeDetailScreen(Screen):
"""
return self._cursor
def cursor_at_edge(self, step: int) -> bool:
"""Edge scroll's gate: the tab's first row going up, its last going down.
On a short frame the vitals above the first action and the rows under the last
are what edge scroll brings back into view.
"""
focus = self._focusables()
return self.row_at_edge(self._row_index % len(focus) if focus else 0, len(focus), step)
def _switch_tab(self, delta: int) -> None:
"""Move the active tab, resetting the cursor and list window to that tab's top."""
if len(self._tabs) < 2:
+4
View File
@@ -678,6 +678,10 @@ class PathComposerScreen(Screen):
"""The body line of the highlighted row, so the session keeps it visible."""
return getattr(self, "_cursor_row", None)
def cursor_at_edge(self, step: int) -> bool:
"""Edge scroll's gate: the first suggestion going up, the last going down."""
return self.row_at_edge(self._index, len(self._rows()), step)
# --- input ---------------------------------------------------------------------
def _insert_hop(self, node: str) -> None:
+9
View File
@@ -412,6 +412,15 @@ class RecordScreen(Screen):
"""Keep the selected action visible while arrowing; scroll free once paging."""
return self._cursor if self._follow else None
def cursor_at_edge(self, step: int) -> bool | None:
"""Edge scroll's gate: the first action going up, the last going down.
``None`` on the Area tab, whose keys move nothing.
"""
if self._tab == _TAB_AREA:
return None
return self.row_at_edge(self._index, len(self._actions), step)
def _header(self) -> Text:
"""The one-line identity above the strip: the discipline, the standing, the metric.
+15 -9
View File
@@ -86,13 +86,17 @@ def _visible_slice(screen: Screen, lines: list[str], viewport: int) -> tuple[lis
# screenful and clamp to the content (see :meth:`Screen.note_metrics`).
screen.note_metrics(total, viewport)
cursor = screen.cursor_line()
# Edge scroll: a page scrolled past its highlight leaves it where it is, off screen if
# need be, until a key brings it back (see Screen.edge_scroll) — so only a page that
# isn't edge-scrolled has its highlight followed.
follow = None if screen.edge_scrolled else cursor
scroll = screen.scroll
if cursor is not None:
if cursor < scroll:
scroll = cursor
elif cursor >= scroll + viewport:
scroll = cursor - viewport + 1
if follow is not None:
if follow < scroll:
scroll = follow
elif follow >= scroll + viewport:
scroll = follow - viewport + 1
scroll = max(0, min(scroll, max(0, total - viewport)))
# Each pinned row takes a top row, leaving that many fewer for content: nudge the scroll
@@ -103,28 +107,30 @@ def _visible_slice(screen: Screen, lines: list[str], viewport: int) -> tuple[lis
pinned = screen.sticky_rows(scroll) if scroll > 0 else []
for _ in range(_PIN_SETTLE_PASSES):
cap = max(1, viewport - len(pinned))
if not pinned or cursor is None or cursor < scroll + cap:
if not pinned or follow is None or follow < scroll + cap:
break
scroll = min(cursor - cap + 1, max(0, total - cap))
scroll = min(follow - cap + 1, max(0, total - cap))
pinned = screen.sticky_rows(scroll) if scroll > 0 else []
screen.scroll = scroll
if pinned:
start = _window_start(scroll, total, viewport, len(pinned), cursor)
start = _window_start(scroll, total, viewport, len(pinned), follow)
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), cursor)
start = _window_start(scroll, total, viewport, len(pinned), follow)
cap = max(1, viewport - len(pinned))
screen.note_cursor_shown(cursor is None or start <= cursor < start + cap)
visible = pinned + lines[start : start + cap]
more_below = start + cap < total
visible = visible + [""] * (viewport - len(visible))
return visible, True, more_below
screen.note_cursor_shown(cursor is None or scroll <= cursor < scroll + viewport)
visible = lines[scroll : scroll + viewport]
more_above = scroll > 0
more_below = scroll + viewport < total
+89
View File
@@ -34,6 +34,13 @@ CANCEL = object()
#: turns it straight into :class:`PopToMenu`, so no caller ever has to test for it.
POP_ALL = object()
#: Edge scroll's keys (see :meth:`Screen.edge_scroll`): the arrows and the way each steps,
#: the jumps to either end, and the keys that bring an off-screen highlight back into view
#: before doing anything — every key that would move it or act on it.
_EDGE_STEPS = {"up": -1, "down": 1}
_EDGE_ENDS = {"home": -1, "ctrl_home": -1, "end": 1, "ctrl_end": 1}
_SNAP_BACK_ACTIONS = frozenset({"up", "down", "enter", "delete"})
class PopToMenu(BaseException):
"""Raised through every awaiting navigation frame to unwind to the main menu.
@@ -126,6 +133,10 @@ class Screen:
bare: bool = False
banner: Sequence[str] | None = None
footnote: str | None = None
#: Whether Home and End jump the highlight to the first and last row, so edge scroll
#: takes the page to its very top and bottom with it (see :meth:`edge_scroll`). Off where
#: they move a text caret instead — the chat's compose line.
home_end_jumps: bool = True
@property
def fkey_lane(self):
@@ -176,6 +187,11 @@ class Screen:
# first paint; irrelevant while not :attr:`grow_only`.
self._viewport_floor = 0
self._width_floor = 0
# Edge scroll (see :meth:`edge_scroll`): whether ↑↓ have scrolled the window past the
# highlight, so the frame has stopped pulling it into view; and whether the last
# paint showed it, which decides the snap-back. Recorded by the frame.
self._edge_scrolled = False
self._cursor_shown = True
# --- rendering -----------------------------------------------------------
@@ -309,6 +325,79 @@ class Screen:
if action == "escape":
self.resolve(CANCEL)
# --- edge scroll ---------------------------------------------------------
def cursor_at_edge(self, step: int) -> bool | None:
"""Whether the highlight can go no further ``step`` way (-1 up, +1 down).
The gate of :meth:`edge_scroll`. ``None`` (the default) where ↑↓ move no highlight —
a plain scroll screen, a prompt whose arrows recall history — and edge scroll stays
out of the way. A screen whose ↑↓ walk a highlight over rows answers for it: ``True``
on the first row going up and the last going down (and either way with no row to
highlight at all), ``False`` everywhere else — most simply through
:meth:`row_at_edge`.
"""
return None
@staticmethod
def row_at_edge(index: int, count: int, step: int) -> bool:
""":meth:`cursor_at_edge`'s answer for a highlight at ``index`` of ``count`` rows."""
if count <= 0:
return True
return index <= 0 if step < 0 else index >= count - 1
def edge_scroll(self, action: str) -> bool:
"""Edge scroll and the snap-back: take ``action`` here, before :meth:`handle` sees it.
**Edge scroll** (JP, 2026-10-04): where the highlight can go no further, an arrow
scrolls the page a line instead, so whatever sits above the first selectable row or
below the last — a node's vitals, a list's lead-in, a closing note — can always be
brought into view. The frame stops pulling the highlight into view while the page is
edge-scrolled, so it may scroll off screen; that is deliberate. **Home** and **End**
go the same way: the highlight jumps to the first or last row as ever, and the page
to its very top or bottom.
**The snap-back**: while the highlight is off screen, a key that would move it or act
on it — the opposite arrow, Enter, Delete — first brings it back into view,
jumping if it has to, and does nothing else. Nothing is run that cannot be seen.
Any other key ends the edge scroll before :meth:`handle` sees it (a typed filter, a
tab switch, a page key), so the frame follows the highlight again from there.
Returns:
Whether the key was taken here; the session passes it to :meth:`handle` only
when it wasn't.
"""
if self.cursor_at_edge(-1) is None:
return False
step = _EDGE_STEPS.get(action)
if step is not None and self.cursor_at_edge(step):
self._edge_scrolled = True
self.scroll_lines(step)
return True
if self._edge_scrolled and not self._cursor_shown and action in _SNAP_BACK_ACTIONS:
self._edge_scrolled = False # the snap-back: this press only brings it home
return True
if action in _EDGE_ENDS and self.home_end_jumps:
self.handle(action)
self._edge_scrolled = True
if _EDGE_ENDS[action] < 0:
self.scroll_to_top()
else:
self.scroll = self._scroll_total # the frame clamps it to the last screenful
return True
self._edge_scrolled = False
return False
@property
def edge_scrolled(self) -> bool:
"""Whether the page is edge-scrolled, so the frame leaves the highlight where it is."""
return self._edge_scrolled
def note_cursor_shown(self, shown: bool) -> None:
"""Record whether the last paint showed the highlight (the frame calls this)."""
self._cursor_shown = shown
def resolve(self, value: Any) -> None:
"""Resolve this screen's future, committing ``value`` (or :data:`CANCEL`).
+8
View File
@@ -908,6 +908,10 @@ class SelectScreen(Screen):
"""Return the body line index of the highlighted row."""
return getattr(self, "_cursor", None)
def cursor_at_edge(self, step: int) -> bool:
"""Edge scroll's gate: the first choice going up, the last going down."""
return self.row_at_edge(self._index, len(self._choices()), step)
# --- input ---------------------------------------------------------------
def handle(self, action: str, data: str = "") -> None:
@@ -1106,6 +1110,10 @@ class ReorderScreen(Screen):
"""Return the body line index of the cursor row, so the session keeps it in view."""
return getattr(self, "_cursor", None)
def cursor_at_edge(self, step: int) -> bool:
"""Edge scroll's gate: the first row going up, the last action going down."""
return self.row_at_edge(self._index, len(self._order) + len(self._actions()), step)
def handle(self, action: str, data: str = "") -> None:
"""Move the cursor, carry a grabbed row, grab/drop, run an action, or cancel on Esc."""
n = len(self._order)
+4 -1
View File
@@ -2135,7 +2135,10 @@ class TuiSession:
self._dispatch("paste", text)
return
top = self.top
if top is not None:
# Edge scroll and its snap-back get the key first (Screen.edge_scroll): an arrow
# past the last row a highlight can reach scrolls the page instead, and a key that
# would act on a highlight scrolled out of view brings it back before doing anything.
if top is not None and not top.edge_scroll(action):
top.handle(action, data)
self.invalidate()
+4
View File
@@ -386,6 +386,10 @@ class TxSweepScreen(Screen):
"""The highlighted action row while ↑/↓ are in use; free scrolling otherwise."""
return getattr(self, "_cursor", None) if self._pin_cursor else None
def cursor_at_edge(self, step: int) -> bool:
"""Edge scroll's gate: the first action going up, the last going down."""
return self.row_at_edge(self._index, len(self._actions), step)
def _action_text(self, key: str, selected: bool) -> Text:
"""One action row: pointer, glyph, and label (current value inlined)."""
text = Text("❯ " if selected else " ", style="cursor" if selected else "")
+39
View File
@@ -729,6 +729,45 @@ def test_node_detail_screen_cursor_and_commit() -> None:
assert resolved == ["timemachine", CANCEL]
def test_the_vitals_come_back_with_edge_scroll() -> None:
"""On a frame too short for the Info tab, ↑ on the first action scrolls the vitals back.
Walking down the actions scrolls the identity and the vitals off the top, and none of
them can take the highlight — so before edge scroll there was no way back to them short
of Home. ↑ on the first action now scrolls the page a line at a time to its top.
"""
from meshterm.ui.tui import frame
actions = [_Action(f"a{i}", "⏳", "", f"Action {i}") for i in range(4)]
screen = _screen(info_actions=actions)
def press(action: str) -> None:
if not screen.edge_scroll(action):
screen.handle(action)
def view() -> list[str]:
screen.note_viewport(5)
visible, _above, _below = frame._visible_slice(screen, screen.render_body(60), 5)
return [_plain([line]) for line in visible]
view()
for _ in range(3):
press("down")
view()
for _ in range(3):
press("up")
view()
assert "Action 0" in "\n".join(view()) # back on the first action…
assert not any("Hub" in line for line in view()) # …the identity still scrolled off
for _ in range(10):
press("up") # edge scroll: the page, not the highlight
view()
page = view()
assert "Hub" in page[0] # the identity line is the top of the view again
press("down") # the snap-back brings the first action home before anything moves
assert any("❯" in line and "Action 0" in line for line in view())
def test_node_detail_carries_no_exit_row() -> None:
"""The page offers no Back row: Esc leaves, and a row repeating it was retired.
+107
View File
@@ -290,6 +290,113 @@ def test_select_filter_matches_callable_title() -> None:
assert _run(screen, "enter") == 1
# --- edge scroll ---------------------------------------------------------------
def _edge_list() -> SelectScreen:
"""Six lines of lead-in over four choices and a closing note: taller than a 4-row view."""
items: list = [Separator(f"lead {i}") for i in range(6)]
items += [Choice(name, name) for name in ("alpha", "beta", "gamma", "delta")]
items.append(Separator("closing note"))
return SelectScreen("pick", items)
def _press(screen, action: str, data: str = "") -> None:
"""Dispatch one key as the session does: edge scroll first, then the screen itself."""
if not screen.edge_scroll(action):
screen.handle(action, data)
def _view(screen, viewport: int = 4) -> list[str]:
"""Paint once through the frame's own slicer and return the visible rows' text."""
visible, _above, _below = frame._visible_slice(screen, screen.render_body(40), viewport)
return [_plain([line]).strip() for line in visible]
def test_edge_scroll_brings_back_what_sits_above_the_first_row() -> None:
"""↑ on the first row scrolls the page a line instead, up to its very top.
Following the highlight down had scrolled the lead-in off, and nothing up there can
take the highlight, so before edge scroll there was no way back to it. The highlight
may scroll off screen meanwhile; the snap-back brings it home without moving it.
"""
screen = _edge_list()
assert _view(screen) == ["lead 3", "lead 4", "lead 5", "❯ alpha"]
_press(screen, "up") # alpha is the first row: the page scrolls a line instead
assert _view(screen)[0] == "lead 2"
for _ in range(5):
_press(screen, "up")
view = _view(screen)
assert view == ["lead 0", "lead 1", "lead 2", "lead 3"] # the top; alpha is off screen
_press(screen, "up")
assert _view(screen) == view # nothing further up: it stays
# The snap-back: ↓ only brings alpha back, still highlighted; the next ↓ moves.
_press(screen, "down")
assert "❯ alpha" in _view(screen)
_press(screen, "down")
assert "❯ beta" in _view(screen)
def test_edge_scroll_brings_back_what_sits_below_the_last_row() -> None:
"""↓ on the last row scrolls the closing note into view; ↑ moves on as ever."""
screen = _edge_list()
_view(screen)
for _ in range(3):
_press(screen, "down")
assert _view(screen) == ["alpha", "beta", "gamma", "❯ delta"] # the note is below
_press(screen, "down") # delta is the last row
assert _view(screen) == ["beta", "gamma", "❯ delta", "closing note"]
_press(screen, "up") # the highlight is in view, so this simply moves it
assert "❯ gamma" in _view(screen)
def test_enter_on_a_highlight_scrolled_off_brings_it_back_instead() -> None:
"""Nothing runs that can't be seen: the first Enter only snaps the highlight back."""
screen = _edge_list()
screen.future = _Fut()
_view(screen)
for _ in range(3):
_press(screen, "up")
assert "❯ alpha" not in _view(screen)
_press(screen, "enter")
assert screen.future.result is _UNSET # nothing was chosen…
assert "❯ alpha" in _view(screen) # …the highlight came back
_press(screen, "enter")
assert screen.future.result == "alpha"
def test_home_and_end_take_the_page_to_its_very_ends() -> None:
"""End shows the closing note under the last row; Home the lead-in over the first."""
screen = _edge_list()
_view(screen)
_press(screen, "end")
assert _view(screen) == ["beta", "gamma", "❯ delta", "closing note"]
_press(screen, "home")
assert _view(screen) == ["lead 0", "lead 1", "lead 2", "lead 3"]
_press(screen, "down") # the snap-back: alpha, the highlight Home left, comes into view
assert "❯ alpha" in _view(screen)
def test_any_other_key_ends_the_edge_scroll() -> None:
"""A typed filter moves the highlight, so the frame follows it again at once."""
screen = _edge_list()
_view(screen)
for _ in range(3):
_press(screen, "up")
_view(screen)
assert screen.edge_scrolled
_press(screen, "text", "g")
assert not screen.edge_scrolled
assert "❯ gamma" in _view(screen)
def test_a_screen_without_a_highlight_keeps_its_own_arrows() -> None:
"""Edge scroll stays out of a plain scroll screen: its ↑↓ are its own."""
screen = ScrollScreen(Text("\n".join(f"row {i}" for i in range(20))), title="t")
for action in ("up", "down", "home", "end", "enter"):
assert not screen.edge_scroll(action)
def test_select_clamps_at_the_ends() -> None:
"""Up on the first row and Down on the last stay put — no cursor in the app rolls over."""
items = [Choice("alpha", 1), Choice("beta", 2), Choice("gamma", 3)]