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:
1 parent
acc1341883
commit
c7b3998a62
13 files changed
+316
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user