diff --git a/CLAUDE.md b/CLAUDE.md index 7dd84ff..637a183 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index 5f1101c..344d861 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -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: diff --git a/meshterm/ui/message_paths_screen.py b/meshterm/ui/message_paths_screen.py index 725385b..f3a2d4d 100644 --- a/meshterm/ui/message_paths_screen.py +++ b/meshterm/ui/message_paths_screen.py @@ -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]: diff --git a/meshterm/ui/node_detail_screen.py b/meshterm/ui/node_detail_screen.py index 4eaeee8..c2e411f 100644 --- a/meshterm/ui/node_detail_screen.py +++ b/meshterm/ui/node_detail_screen.py @@ -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: diff --git a/meshterm/ui/path_composer.py b/meshterm/ui/path_composer.py index d9abd5a..c6b10b5 100644 --- a/meshterm/ui/path_composer.py +++ b/meshterm/ui/path_composer.py @@ -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: diff --git a/meshterm/ui/records_screen.py b/meshterm/ui/records_screen.py index d89ba3a..d960d86 100644 --- a/meshterm/ui/records_screen.py +++ b/meshterm/ui/records_screen.py @@ -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. diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index 9f2c5f5..3927eaf 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -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 diff --git a/meshterm/ui/tui/screen.py b/meshterm/ui/tui/screen.py index f1b222d..f450980 100644 --- a/meshterm/ui/tui/screen.py +++ b/meshterm/ui/tui/screen.py @@ -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`). diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index 3bfb4af..e82f916 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -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) diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index cfc0898..3644696 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -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() diff --git a/meshterm/ui/tx_screen.py b/meshterm/ui/tx_screen.py index b66555e..47cf426 100644 --- a/meshterm/ui/tx_screen.py +++ b/meshterm/ui/tx_screen.py @@ -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 "") diff --git a/tests/test_node_detail.py b/tests/test_node_detail.py index 4e364a9..6587056 100644 --- a/tests/test_node_detail.py +++ b/tests/test_node_detail.py @@ -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. diff --git a/tests/test_tui.py b/tests/test_tui.py index 75d926e..b5887fa 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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)]