Navigate the TUI like a text editor
Make sticky headers a reusable Screen feature and lift paging and section jumps onto the base Screen, so every scrollable surface shares one implementation: the frame records each render's body height and viewport, and the shared helpers page by a screenful, clamp to the content, and walk between the boundaries a screen records in _sticky_headers (a select list's group dividers, the chat transcript's day dividers). Remap keys to match a good editor: - PageUp/PageDown scroll by a screenful rather than a fixed step. - Ctrl+Home/End reach the top/bottom (the live tail in chat). - Ctrl+PageUp/PageDown jump to the current/previous and next section. - In chat, Home/End and Ctrl+Left/Right act on the compose line; word motion (start of current word, else previous/next) is added to the shared line editor, so every text prompt gains it too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
8906e4ed53
commit
bdbe5cce30
8 files changed
+506
-103
No files matched your search
+81
-25
@@ -33,10 +33,6 @@ from .tui.spinner import Spinner
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
|
||||
#: Fixed page step for PageUp/PageDown while scrolling the transcript (the screen isn't
|
||||
#: told the viewport height, so a constant keeps paging predictable).
|
||||
_PAGE = 10
|
||||
|
||||
#: How many past messages to load into the transcript when a conversation opens.
|
||||
_HISTORY_LIMIT = 200
|
||||
|
||||
@@ -183,10 +179,10 @@ class ChatScreen(Screen):
|
||||
"""Key hint, reflecting whether a message is picked for reply (channels only)."""
|
||||
if not self._is_channel:
|
||||
if any(m.outbound and m.acked is False for m in self._messages):
|
||||
return "Enter send · Ctrl-R retry failed · ↑↓ scroll · Esc back"
|
||||
return "Enter send · ↑↓/PgUp scroll · End latest · Esc back"
|
||||
return "Enter send · Ctrl-R retry failed · ↑↓/PgUp scroll · ^End latest · Esc back"
|
||||
return "Enter send · ↑↓/PgUp scroll · ^End latest · Esc back"
|
||||
if self._selected is not None:
|
||||
return "Enter reply (@mention) · ↑↓ pick · End/Esc cancel"
|
||||
return "Enter reply (@mention) · ↑↓ pick · ^End/Esc cancel"
|
||||
return "Enter send · ↑ pick a message to reply · Esc back"
|
||||
|
||||
# --- live updates --------------------------------------------------------
|
||||
@@ -311,6 +307,10 @@ class ChatScreen(Screen):
|
||||
view (channels only; direct threads never select).
|
||||
"""
|
||||
lines: list[str] = []
|
||||
# Record each day divider as a sticky-header candidate, so the divider governing the
|
||||
# topmost visible message is re-pinned to the top row once it scrolls off — the same
|
||||
# base Screen.sticky_header the conversation picker uses for its section headings.
|
||||
self._sticky_headers = []
|
||||
prev_group: Optional[tuple[bool, str]] = None
|
||||
prev_day = None
|
||||
for idx, message in enumerate(self._messages):
|
||||
@@ -324,9 +324,11 @@ class ChatScreen(Screen):
|
||||
if new_day:
|
||||
if lines:
|
||||
lines += render_lines(Text(""), width)
|
||||
lines += render_lines(
|
||||
divider = render_lines(
|
||||
Text(f"── {stamp:%a} {stamp:%b} {stamp.day} ──", style="muted"), width
|
||||
)
|
||||
self._sticky_headers.append((len(lines), divider[0]))
|
||||
lines += divider
|
||||
if new_day or group != prev_group:
|
||||
if not new_day and lines:
|
||||
lines += render_lines(Text(""), width) # gap between sender groups
|
||||
@@ -458,7 +460,12 @@ class ChatScreen(Screen):
|
||||
self._handle_flat(action, data)
|
||||
|
||||
def _handle_flat(self, action: str, data: str = "") -> None:
|
||||
"""Direct-chat input: send on Enter, scroll the transcript, edit, or leave on Esc."""
|
||||
"""Direct-chat input: send on Enter, scroll the transcript, edit, or leave on Esc.
|
||||
|
||||
Home/End and Ctrl+←/→ act on the compose line (like any text field); scrolling the
|
||||
transcript is on the arrows, PageUp/PageDown (a screenful), Ctrl+Home/End (top / live
|
||||
tail), and Ctrl+PageUp/PageDown (previous / next day divider).
|
||||
"""
|
||||
if action == "enter":
|
||||
self._submit()
|
||||
elif action == "retry":
|
||||
@@ -466,24 +473,30 @@ class ChatScreen(Screen):
|
||||
elif action == "escape":
|
||||
self.resolve(CANCEL)
|
||||
elif action == "up":
|
||||
self._stick = False
|
||||
self.scroll = max(0, self.scroll - 1)
|
||||
self._detach_and(self.scroll_lines, -1)
|
||||
elif action == "pageup":
|
||||
self._stick = False
|
||||
self.scroll = max(0, self.scroll - _PAGE)
|
||||
self._detach_and(self.scroll_pages, -1)
|
||||
elif action == "down":
|
||||
self.scroll += 1
|
||||
self._detach_and(self.scroll_lines, 1)
|
||||
elif action == "pagedown":
|
||||
self.scroll += _PAGE
|
||||
elif action == "home":
|
||||
self._stick = False
|
||||
self.scroll = 0
|
||||
elif action == "end":
|
||||
self._stick = True
|
||||
self._detach_and(self.scroll_pages, 1)
|
||||
elif action == "ctrl_home":
|
||||
self._detach_and(self.scroll_to_top)
|
||||
elif action == "ctrl_pageup":
|
||||
self._detach_and(self.scroll_to_section_start)
|
||||
elif action == "ctrl_pagedown":
|
||||
self._detach_and(self.scroll_to_next_section)
|
||||
elif action == "ctrl_end":
|
||||
self._stick = True # snap back to the live tail / compose line
|
||||
else:
|
||||
if self._editor.edit(action, data):
|
||||
self._status = "" # trimming clears the "too long" notice
|
||||
self._stick = True # typing snaps back to the live tail
|
||||
self._stick = True # touching the compose line snaps back to the live tail
|
||||
|
||||
def _detach_and(self, move: Callable[..., None], *args: Any) -> None:
|
||||
"""Detach from the live tail and run a scroll move (the shared scroll helpers)."""
|
||||
self._stick = False
|
||||
move(*args)
|
||||
|
||||
def _handle_channel(self, action: str, data: str = "") -> None:
|
||||
"""Channel input: arrows pick a message to reply to; Enter sends or starts a reply.
|
||||
@@ -507,16 +520,20 @@ class ChatScreen(Screen):
|
||||
elif action == "up":
|
||||
self._move_selection(-1)
|
||||
elif action == "pageup":
|
||||
self._move_selection(-_PAGE)
|
||||
self._move_selection(-self._page_step)
|
||||
elif action == "down":
|
||||
self._move_selection(1)
|
||||
elif action == "pagedown":
|
||||
self._move_selection(_PAGE)
|
||||
elif action == "home":
|
||||
self._move_selection(self._page_step)
|
||||
elif action == "ctrl_home":
|
||||
if self._messages:
|
||||
self._selected = 0
|
||||
self._stick = False
|
||||
elif action == "end":
|
||||
elif action == "ctrl_pageup":
|
||||
self._select_section(-1)
|
||||
elif action == "ctrl_pagedown":
|
||||
self._select_section(1)
|
||||
elif action == "ctrl_end":
|
||||
self._clear_selection()
|
||||
self._stick = True # jump back to the live tail / compose line
|
||||
else:
|
||||
@@ -548,6 +565,45 @@ class ChatScreen(Screen):
|
||||
self._selected = max(0, target)
|
||||
self._stick = False
|
||||
|
||||
def _select_section(self, direction: int) -> None:
|
||||
"""Jump the reply selection to the first message of the previous/next day.
|
||||
|
||||
The selection-space analogue of the transcript's Ctrl+PageUp/PageDown day jump: down
|
||||
moves to the first message of the following day (dropping to the tail when there's no
|
||||
later day); up moves to the first message of the current day, or the previous day's
|
||||
when already atop one.
|
||||
"""
|
||||
if not self._messages:
|
||||
return
|
||||
starts = self._day_start_indices()
|
||||
current = self._selected if self._selected is not None else len(self._messages) - 1
|
||||
if direction > 0:
|
||||
target = next((s for s in starts if s > current), None)
|
||||
if target is None:
|
||||
self._clear_selection()
|
||||
self._stick = True
|
||||
return
|
||||
else:
|
||||
governing = max((s for s in starts if s <= current), default=0)
|
||||
target = (
|
||||
governing
|
||||
if governing < current
|
||||
else max((s for s in starts if s < current), default=0)
|
||||
)
|
||||
self._selected = target
|
||||
self._stick = False
|
||||
|
||||
def _day_start_indices(self) -> list[int]:
|
||||
"""Message indices that begin a new local-day group — matching the transcript dividers."""
|
||||
starts: list[int] = []
|
||||
prev_day = None
|
||||
for i, message in enumerate(self._messages):
|
||||
day = message.created_at.astimezone().date()
|
||||
if day != prev_day:
|
||||
starts.append(i)
|
||||
prev_day = day
|
||||
return starts
|
||||
|
||||
def _clear_selection(self) -> None:
|
||||
"""Drop any reply selection (focus returns to the compose line)."""
|
||||
self._selected = None
|
||||
|
||||
@@ -15,7 +15,7 @@ from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from .render import render_lines
|
||||
from .screen import Screen, ScrollScreen
|
||||
from .screen import Screen
|
||||
|
||||
|
||||
def _visible_slice(screen: Screen, lines: list[str], viewport: int) -> tuple[list[str], bool, bool]:
|
||||
@@ -37,8 +37,9 @@ def _visible_slice(screen: Screen, lines: list[str], viewport: int) -> tuple[lis
|
||||
A tuple of (padded visible lines, more-above, more-below).
|
||||
"""
|
||||
total = len(lines)
|
||||
if isinstance(screen, ScrollScreen):
|
||||
screen.note_viewport(viewport)
|
||||
# Record the body height and viewport so the screen's shared scroll helpers can page by a
|
||||
# screenful and clamp to the content (see :meth:`Screen.note_metrics`).
|
||||
screen.note_metrics(total, viewport)
|
||||
cursor = screen.cursor_line()
|
||||
|
||||
scroll = screen.scroll
|
||||
|
||||
@@ -57,10 +57,42 @@ class _LineEditor:
|
||||
self.cursor = 0
|
||||
elif action == "end":
|
||||
self.cursor = len(self.text)
|
||||
elif action == "ctrl_left":
|
||||
self.cursor = self._word_left(self.cursor)
|
||||
elif action == "ctrl_right":
|
||||
self.cursor = self._word_right(self.cursor)
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _word_left(self, pos: int) -> int:
|
||||
"""Index of the start of the word at/left of ``pos`` (the previous word if already there).
|
||||
|
||||
Skips any whitespace immediately left of the cursor, then the run of word characters, so
|
||||
from mid-word it lands on that word's first character and from a word start it steps back
|
||||
to the previous word — the usual Ctrl+Left behavior of a text editor.
|
||||
"""
|
||||
i = pos
|
||||
while i > 0 and self.text[i - 1].isspace():
|
||||
i -= 1
|
||||
while i > 0 and not self.text[i - 1].isspace():
|
||||
i -= 1
|
||||
return i
|
||||
|
||||
def _word_right(self, pos: int) -> int:
|
||||
"""Index of the start of the next word after ``pos`` (or the line end if none remains).
|
||||
|
||||
Skips the current run of word characters, then the whitespace after it, landing on the
|
||||
first character of the following word — the usual Ctrl+Right behavior.
|
||||
"""
|
||||
n = len(self.text)
|
||||
i = pos
|
||||
while i < n and not self.text[i].isspace():
|
||||
i += 1
|
||||
while i < n and self.text[i].isspace():
|
||||
i += 1
|
||||
return i
|
||||
|
||||
def render(self, mask: bool = False, *, overflow_at: Optional[int] = None) -> Text:
|
||||
"""Render the current line with a reverse-video cursor cell.
|
||||
|
||||
|
||||
+112
-37
@@ -56,6 +56,15 @@ class Screen:
|
||||
"""Initialize scroll state and the (later-assigned) result future."""
|
||||
self.scroll = 0
|
||||
self.future: Optional[asyncio.Future] = None
|
||||
# (body-line index, rendered ANSI line) for each header eligible to be pinned to the
|
||||
# top row once it scrolls off. A screen that wants sticky headers rebuilds this list
|
||||
# while rendering its body (see :meth:`sticky_header`); the default is no headers.
|
||||
self._sticky_headers: list[tuple[int, str]] = []
|
||||
# The last render's body height and viewport, recorded by the frame (:meth:`note_metrics`)
|
||||
# so the shared scroll helpers can page by a screenful of the *current* terminal and
|
||||
# clamp to the content without every caller threading the sizes through.
|
||||
self._scroll_total = 1
|
||||
self._scroll_viewport = 1
|
||||
|
||||
# --- rendering -----------------------------------------------------------
|
||||
|
||||
@@ -82,10 +91,26 @@ class Screen:
|
||||
"""An already-rendered body line to pin to the top row once ``scroll`` moves past it.
|
||||
|
||||
Lets a grouped list keep its current section heading in view after the heading itself
|
||||
has scrolled off. Returns an ANSI line (e.g. a section separator) to pin, or ``None``
|
||||
for no pinned header. ``scroll`` is the offset the body is about to be sliced at.
|
||||
has scrolled off — a select screen's ``Channels``/``Direct`` divider, or the chat
|
||||
transcript's ``── Wed Jul 8 ──`` day divider. ``scroll`` is the offset the body is
|
||||
about to be sliced at; the frame draws the returned line as the top row.
|
||||
|
||||
The shared rule: among the headers a screen recorded in :attr:`_sticky_headers` while
|
||||
rendering, find the last one at or above ``scroll`` (the one *governing* the top visible
|
||||
row) and pin it — unless it is itself the top visible row (nothing to duplicate) or there
|
||||
is none above. A screen opts in simply by populating :attr:`_sticky_headers`; the empty
|
||||
default means no pinning.
|
||||
"""
|
||||
return None
|
||||
governing: Optional[str] = None
|
||||
governing_at = -1
|
||||
for idx, line in self._sticky_headers:
|
||||
if idx <= scroll:
|
||||
governing, governing_at = line, idx
|
||||
else:
|
||||
break # headers are recorded in body order; nothing past here can govern
|
||||
if governing is None or governing_at == scroll:
|
||||
return None # no header above, or it's already the top visible row
|
||||
return governing
|
||||
|
||||
# --- input ---------------------------------------------------------------
|
||||
|
||||
@@ -94,9 +119,10 @@ class Screen:
|
||||
|
||||
Args:
|
||||
action: One of ``up``, ``down``, ``pageup``, ``pagedown``, ``home``, ``end``,
|
||||
``left``, ``right``, ``shift_up``, ``shift_down``, ``shift_left``,
|
||||
``shift_right``, ``enter``, ``escape``, ``backspace``, ``delete``,
|
||||
``space``, ``tab``, or ``text`` (with ``data`` set to the character).
|
||||
``ctrl_home``, ``ctrl_end``, ``ctrl_pageup``, ``ctrl_pagedown``, ``left``,
|
||||
``right``, ``ctrl_left``, ``ctrl_right``, ``shift_up``, ``shift_down``,
|
||||
``shift_left``, ``shift_right``, ``enter``, ``escape``, ``backspace``,
|
||||
``delete``, ``space``, ``tab``, or ``text`` (with ``data`` set to the character).
|
||||
data: The typed character when ``action`` is ``text``.
|
||||
"""
|
||||
if action == "escape":
|
||||
@@ -113,16 +139,72 @@ class Screen:
|
||||
|
||||
# --- scrolling helpers (shared) ------------------------------------------
|
||||
|
||||
def scroll_by(self, delta: int, total: int, viewport: int) -> None:
|
||||
"""Adjust :attr:`scroll` by ``delta`` lines, clamped to the content.
|
||||
def note_metrics(self, total: int, viewport: int) -> None:
|
||||
"""Record the last render's body height and viewport (the frame calls this each paint).
|
||||
|
||||
Args:
|
||||
delta: Lines to move (negative scrolls up).
|
||||
total: Total body line count.
|
||||
viewport: Visible body height in lines.
|
||||
The shared scroll/section jumps below size themselves from these, so a PageDown moves
|
||||
by a screenful of the current terminal rather than a fixed constant, and the top/bottom
|
||||
clamps track the real content height without the caller passing the sizes in.
|
||||
"""
|
||||
self._scroll_total = max(1, total)
|
||||
self._scroll_viewport = max(1, viewport)
|
||||
|
||||
def note_viewport(self, viewport: int) -> None:
|
||||
"""Record just the viewport height (for callers that don't know the body total)."""
|
||||
self._scroll_viewport = max(1, viewport)
|
||||
|
||||
@property
|
||||
def _page_step(self) -> int:
|
||||
"""Lines a PageUp/PageDown moves: a screenful, bar one row kept for continuity."""
|
||||
return max(1, self._scroll_viewport - 1)
|
||||
|
||||
def scroll_by(self, delta: int, total: int, viewport: int) -> None:
|
||||
"""Adjust :attr:`scroll` by ``delta`` lines, clamped to the given content bounds."""
|
||||
self.scroll = _clamp_scroll(self.scroll + delta, total, viewport)
|
||||
|
||||
def scroll_lines(self, delta: int) -> None:
|
||||
"""Scroll by ``delta`` lines, clamped to the last-recorded content bounds."""
|
||||
self.scroll = _clamp_scroll(
|
||||
self.scroll + delta, self._scroll_total, self._scroll_viewport
|
||||
)
|
||||
|
||||
def scroll_pages(self, pages: int) -> None:
|
||||
"""Scroll by ``pages`` screenfuls (negative scrolls up)."""
|
||||
self.scroll_lines(pages * self._page_step)
|
||||
|
||||
def scroll_to_top(self) -> None:
|
||||
"""Jump the view to the first line."""
|
||||
self.scroll = 0
|
||||
|
||||
def scroll_to_bottom(self) -> None:
|
||||
"""Jump the view to the last screenful."""
|
||||
self.scroll = max(0, self._scroll_total - self._scroll_viewport)
|
||||
|
||||
def scroll_to_section_start(self) -> None:
|
||||
"""Move to the top of the section holding the top visible line.
|
||||
|
||||
Sections are delimited by the recorded :attr:`_sticky_headers` (a select list's group
|
||||
dividers, the chat transcript's day dividers). If that heading is already the top line,
|
||||
move to the *previous* section instead — so repeated presses walk up section by section,
|
||||
the way a text editor's paragraph jump does. Falls back to the very top with no sections.
|
||||
"""
|
||||
offsets = [idx for idx, _ in self._sticky_headers]
|
||||
governing = max((o for o in offsets if o <= self.scroll), default=None)
|
||||
if governing is None:
|
||||
target = 0
|
||||
elif governing < self.scroll:
|
||||
target = governing # up to the top of the section we're inside
|
||||
else:
|
||||
target = max((o for o in offsets if o < self.scroll), default=0)
|
||||
self.scroll = _clamp_scroll(target, self._scroll_total, self._scroll_viewport)
|
||||
|
||||
def scroll_to_next_section(self) -> None:
|
||||
"""Move to the start of the next section below the top visible line (else the bottom)."""
|
||||
offsets = [idx for idx, _ in self._sticky_headers]
|
||||
nxt = min((o for o in offsets if o > self.scroll), default=None)
|
||||
target = nxt if nxt is not None else self._scroll_total
|
||||
self.scroll = _clamp_scroll(target, self._scroll_total, self._scroll_viewport)
|
||||
|
||||
|
||||
def _clamp_scroll(scroll: int, total: int, viewport: int) -> int:
|
||||
"""Clamp a scroll offset to the valid range for the content and viewport.
|
||||
@@ -141,8 +223,10 @@ def _clamp_scroll(scroll: int, total: int, viewport: int) -> int:
|
||||
class ScrollScreen(Screen):
|
||||
"""A read-only screen that shows a Rich renderable in a scrollable viewport.
|
||||
|
||||
Used for tool result windows and any long list. Content taller than the viewport
|
||||
scrolls with the arrow keys / PageUp / PageDown / Home / End; Esc dismisses it.
|
||||
Used for tool result windows and any long list. Content taller than the viewport scrolls
|
||||
with the arrows / PageUp / PageDown (a screenful) / Home / End (or Ctrl+Home / Ctrl+End);
|
||||
Esc dismisses it. Result windows carry no sections, so Ctrl+PageUp/PageDown just reach the
|
||||
top/bottom.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -166,40 +250,31 @@ class ScrollScreen(Screen):
|
||||
self.title = title
|
||||
self.footer_hint = footer_hint
|
||||
self.floating = floating
|
||||
# The last viewport height the session rendered with, so PageUp/PageDown and the
|
||||
# End key can move by a full page without the session having to pass it in.
|
||||
self._viewport = 1
|
||||
self._total = 1
|
||||
|
||||
def render_body(self, width: int) -> list[str]:
|
||||
"""Render the wrapped content to ANSI lines and remember the total count."""
|
||||
lines = render_lines(self._renderable, width)
|
||||
self._total = max(1, len(lines))
|
||||
self._scroll_total = max(1, len(lines))
|
||||
return lines
|
||||
|
||||
def note_viewport(self, viewport: int) -> None:
|
||||
"""Record the viewport height the session is about to render with.
|
||||
|
||||
Args:
|
||||
viewport: Visible body height in lines.
|
||||
"""
|
||||
self._viewport = max(1, viewport)
|
||||
|
||||
def handle(self, action: str, data: str = "") -> None:
|
||||
"""Scroll the viewport or dismiss the screen."""
|
||||
page = max(1, self._viewport - 1)
|
||||
"""Scroll the viewport (by line, page, or to an edge) or dismiss the screen."""
|
||||
if action == "up":
|
||||
self.scroll_by(-1, self._total, self._viewport)
|
||||
self.scroll_lines(-1)
|
||||
elif action == "down":
|
||||
self.scroll_by(1, self._total, self._viewport)
|
||||
self.scroll_lines(1)
|
||||
elif action == "pageup":
|
||||
self.scroll_by(-page, self._total, self._viewport)
|
||||
self.scroll_pages(-1)
|
||||
elif action in ("pagedown", "space"):
|
||||
self.scroll_by(page, self._total, self._viewport)
|
||||
elif action == "home":
|
||||
self.scroll = 0
|
||||
elif action == "end":
|
||||
self.scroll = max(0, self._total - self._viewport)
|
||||
self.scroll_pages(1)
|
||||
elif action in ("home", "ctrl_home"):
|
||||
self.scroll_to_top()
|
||||
elif action in ("end", "ctrl_end"):
|
||||
self.scroll_to_bottom()
|
||||
elif action == "ctrl_pageup":
|
||||
self.scroll_to_section_start()
|
||||
elif action == "ctrl_pagedown":
|
||||
self.scroll_to_next_section()
|
||||
elif action in ("escape", "enter"):
|
||||
self.resolve(None)
|
||||
|
||||
|
||||
+57
-31
@@ -56,10 +56,6 @@ class Separator:
|
||||
|
||||
Item = "Choice | Separator"
|
||||
|
||||
#: How many rows PageUp/PageDown move the highlight. A fixed step (the screen isn't told the
|
||||
#: viewport height) that keeps paging predictable, matching the chat transcript's own paging.
|
||||
_PAGE = 10
|
||||
|
||||
|
||||
class SelectScreen(Screen):
|
||||
"""A grouped, filterable, single-choice list.
|
||||
@@ -129,6 +125,50 @@ class SelectScreen(Screen):
|
||||
rows = self._rows() if rows is None else rows
|
||||
return [it for it in rows if isinstance(it, Choice)]
|
||||
|
||||
def _section_starts(self) -> list[int]:
|
||||
"""Choice indices that begin a section — the first choice after each run of separators.
|
||||
|
||||
Drives Ctrl+PageUp/PageDown section jumps. Empty while filtering, since the filtered
|
||||
list is flattened to bare matches with no group headings to jump between.
|
||||
"""
|
||||
if self._filter:
|
||||
return []
|
||||
starts: list[int] = []
|
||||
idx = 0
|
||||
fresh = True # the next choice opens a section (top of the list, or just past a heading)
|
||||
for item in self._items:
|
||||
if isinstance(item, Separator):
|
||||
fresh = True
|
||||
elif isinstance(item, Choice):
|
||||
if fresh:
|
||||
starts.append(idx)
|
||||
fresh = False
|
||||
idx += 1
|
||||
return starts
|
||||
|
||||
def _jump_section(self, direction: int) -> None:
|
||||
"""Move the highlight to the next section (``+1``) or the current/previous one (``-1``).
|
||||
|
||||
Mirrors the scroll-based section jump of read-only screens, but in choice space: down
|
||||
lands on the first choice of the following section; up lands on the first choice of the
|
||||
section we're in, or the previous section's when already at a section start.
|
||||
"""
|
||||
starts = self._section_starts()
|
||||
if not starts:
|
||||
return
|
||||
if direction > 0:
|
||||
nxt = next((s for s in starts if s > self._index), None)
|
||||
if nxt is not None:
|
||||
self._index = nxt
|
||||
else:
|
||||
at_or_before = [s for s in starts if s <= self._index]
|
||||
if not at_or_before:
|
||||
self._index = 0
|
||||
elif at_or_before[-1] < self._index:
|
||||
self._index = at_or_before[-1]
|
||||
else:
|
||||
self._index = at_or_before[-2] if len(at_or_before) >= 2 else 0
|
||||
|
||||
# --- rendering -----------------------------------------------------------
|
||||
|
||||
def render_body(self, width: int) -> list[str]:
|
||||
@@ -139,15 +179,16 @@ class SelectScreen(Screen):
|
||||
selected = choices[self._index] if choices else None
|
||||
|
||||
lines: list[str] = []
|
||||
# (body-line index, rendered line) for each separator, so a section heading that
|
||||
# scrolls off can be re-pinned to the top row (see :meth:`sticky_header`).
|
||||
self._separator_lines: list[tuple[int, str]] = []
|
||||
# Record each section heading as a sticky-header candidate, so one that scrolls off is
|
||||
# re-pinned to the top row by the base Screen.sticky_header. Filtering flattens the list
|
||||
# to bare choices (see _rows), so this stays empty then and nothing is pinned.
|
||||
self._sticky_headers = []
|
||||
if self._filter:
|
||||
lines.append(render_to_ansi(Text(f"/{self._filter}", style="warn"), width))
|
||||
for item in rows:
|
||||
if isinstance(item, Separator):
|
||||
sep = render_to_ansi(Text(item.title, style="muted"), width)
|
||||
self._separator_lines.append((len(lines), sep))
|
||||
self._sticky_headers.append((len(lines), sep))
|
||||
lines.append(sep)
|
||||
continue
|
||||
is_sel = item is selected
|
||||
@@ -174,25 +215,6 @@ class SelectScreen(Screen):
|
||||
"""Return the body line index of the highlighted row."""
|
||||
return getattr(self, "_cursor", None)
|
||||
|
||||
def sticky_header(self, scroll: int) -> Optional[str]:
|
||||
"""The section heading governing the top visible row, when it has scrolled off.
|
||||
|
||||
Returns the rendered separator line for the group the first visible row belongs to,
|
||||
so the "Channels"/"Direct"-style heading stays pinned as you scroll down into a long
|
||||
section. Returns ``None`` when that heading is itself still on screen (nothing to pin)
|
||||
or while filtering (headings are hidden then).
|
||||
"""
|
||||
governing: Optional[str] = None
|
||||
governing_at = -1
|
||||
for idx, line in getattr(self, "_separator_lines", ()):
|
||||
if idx <= scroll:
|
||||
governing, governing_at = line, idx
|
||||
else:
|
||||
break
|
||||
if governing is None or governing_at == scroll:
|
||||
return None # no heading above, or it's already the top visible row
|
||||
return governing
|
||||
|
||||
# --- input ---------------------------------------------------------------
|
||||
|
||||
def handle(self, action: str, data: str = "") -> None:
|
||||
@@ -209,13 +231,17 @@ class SelectScreen(Screen):
|
||||
len(choices) - 1, self._index + 1
|
||||
)
|
||||
elif action == "pageup":
|
||||
self._index = max(0, self._index - _PAGE)
|
||||
self._index = max(0, self._index - self._page_step)
|
||||
elif action == "pagedown":
|
||||
self._index = min(len(choices) - 1, self._index + _PAGE) if choices else 0
|
||||
elif action == "home":
|
||||
self._index = min(len(choices) - 1, self._index + self._page_step) if choices else 0
|
||||
elif action in ("home", "ctrl_home"):
|
||||
self._index = 0
|
||||
elif action == "end":
|
||||
elif action in ("end", "ctrl_end"):
|
||||
self._index = max(0, len(choices) - 1)
|
||||
elif action == "ctrl_pagedown":
|
||||
self._jump_section(1)
|
||||
elif action == "ctrl_pageup":
|
||||
self._jump_section(-1)
|
||||
elif action == "enter":
|
||||
if choices:
|
||||
self.resolve(choices[self._index].value)
|
||||
|
||||
@@ -44,6 +44,12 @@ _KEY_ACTIONS: dict[Any, str] = {
|
||||
Keys.PageDown: "pagedown",
|
||||
Keys.Home: "home",
|
||||
Keys.End: "end",
|
||||
Keys.ControlHome: "ctrl_home",
|
||||
Keys.ControlEnd: "ctrl_end",
|
||||
Keys.ControlPageUp: "ctrl_pageup",
|
||||
Keys.ControlPageDown: "ctrl_pagedown",
|
||||
Keys.ControlLeft: "ctrl_left",
|
||||
Keys.ControlRight: "ctrl_right",
|
||||
Keys.Enter: "enter",
|
||||
Keys.Escape: "escape",
|
||||
Keys.Backspace: "backspace",
|
||||
|
||||
@@ -789,6 +789,117 @@ def test_channel_transcript_groups_by_sender() -> None:
|
||||
assert stamps[0] != stamps[1] # grouped Alice messages show distinct times
|
||||
|
||||
|
||||
def _two_day_messages():
|
||||
"""Two days of direct messages, long enough to overflow a small viewport."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
base = datetime(2026, 7, 5, 9, 0, tzinfo=timezone.utc)
|
||||
day1 = [ChatMessage(text=f"day1-{i}", peer="d4e5f6a7", created_at=base + timedelta(minutes=i))
|
||||
for i in range(4)]
|
||||
day2 = [ChatMessage(text=f"day2-{i}", peer="d4e5f6a7",
|
||||
created_at=base + timedelta(days=1, minutes=i)) for i in range(4)]
|
||||
return day1 + day2
|
||||
|
||||
|
||||
def test_chat_sticky_header_pins_the_governing_day_divider() -> None:
|
||||
"""The day divider above the top row pins there once it scrolls off (like the picker)."""
|
||||
screen = _screen(_StubSession(), send=None, messages=_two_day_messages())
|
||||
screen.render_body(60)
|
||||
(idx0, div0), (idx1, div1) = screen._sticky_headers
|
||||
assert screen.sticky_header(0) is None # first divider is itself the top row
|
||||
assert screen.sticky_header(idx1 - 1) == div0 # still within day one — its divider pins
|
||||
assert screen.sticky_header(idx1) is None # day two's divider is now the top row
|
||||
assert screen.sticky_header(idx1 + 1) == div1 # scrolled past it — day two's pins
|
||||
|
||||
|
||||
def test_chat_frame_pins_a_day_divider_when_stuck_to_the_newest() -> None:
|
||||
"""Rendered through the frame at the tail, a day divider occupies the pinned top row."""
|
||||
from meshterm.ui.tui import frame
|
||||
|
||||
screen = _screen(_StubSession(), send=None, messages=_two_day_messages())
|
||||
lines = screen.render_body(60) # sticks to the newest message, scrolling early days off
|
||||
visible, above, _below = frame._visible_slice(screen, lines, 6)
|
||||
top = _strip_ansi(visible[0]).strip()
|
||||
assert top.startswith("──") and "Jul" in top # a day divider is pinned to the top row
|
||||
assert above is True # and the frame flags there's more above the pin
|
||||
|
||||
|
||||
def test_chat_home_end_and_word_keys_move_the_compose_cursor() -> None:
|
||||
"""In a chat, Home/End and Ctrl+←/→ act on the compose line, not the transcript scroll."""
|
||||
screen = _screen(_StubSession(), send=None, messages=_two_day_messages())
|
||||
for ch in "hello world":
|
||||
screen.handle("text", ch)
|
||||
assert screen._editor.cursor == 11
|
||||
screen.handle("home")
|
||||
assert screen._editor.cursor == 0 # line start, not scroll-to-top
|
||||
screen.handle("end")
|
||||
assert screen._editor.cursor == 11
|
||||
screen.handle("ctrl_left")
|
||||
assert screen._editor.cursor == 6 # start of "world"
|
||||
|
||||
|
||||
def test_chat_scroll_keys_detach_and_reattach_to_the_tail() -> None:
|
||||
"""PageUp/Ctrl+Home detach from the live tail; Ctrl+End snaps back to it."""
|
||||
screen = _screen(_StubSession(), send=None, messages=_two_day_messages())
|
||||
lines = screen.render_body(60)
|
||||
screen.note_metrics(total=len(lines), viewport=5)
|
||||
screen.handle("pageup")
|
||||
assert screen._stick is False # a screenful up detaches from the tail
|
||||
screen.handle("ctrl_home")
|
||||
assert screen.scroll == 0 and screen._stick is False
|
||||
screen.handle("ctrl_end")
|
||||
assert screen._stick is True # re-attached to the newest message
|
||||
|
||||
|
||||
def test_chat_ctrl_page_scrolls_between_day_dividers() -> None:
|
||||
"""Ctrl+PageDown/PageUp move the transcript scroll between day dividers."""
|
||||
screen = _screen(_StubSession(), send=None, messages=_two_day_messages())
|
||||
lines = screen.render_body(60)
|
||||
screen.note_metrics(total=len(lines), viewport=5)
|
||||
(idx0, _), (idx1, _) = screen._sticky_headers
|
||||
screen.scroll = 0
|
||||
screen.handle("ctrl_pagedown")
|
||||
assert screen.scroll == idx1 and screen._stick is False # to the second day's divider
|
||||
screen.handle("ctrl_pageup")
|
||||
assert screen.scroll == idx0 # back to the first day's divider
|
||||
|
||||
|
||||
def _two_day_channel_messages():
|
||||
"""Channel messages spanning two local days (one sender), for section-jump tests."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
base = datetime(2026, 7, 5, 9, 0, tzinfo=timezone.utc)
|
||||
day1 = [ChatMessage(text=f"Alice: d1-{i}", is_channel=True, channel_idx=0,
|
||||
created_at=base + timedelta(minutes=i)) for i in range(3)]
|
||||
day2 = [ChatMessage(text=f"Alice: d2-{i}", is_channel=True, channel_idx=0,
|
||||
created_at=base + timedelta(days=1, minutes=i)) for i in range(3)]
|
||||
return day1 + day2
|
||||
|
||||
|
||||
def test_chat_channel_ctrl_page_selects_across_days() -> None:
|
||||
"""In a channel, Ctrl+PageDown/PageUp move the reply selection to day boundaries."""
|
||||
screen = _channel_screen(_two_day_channel_messages())
|
||||
starts = screen._day_start_indices()
|
||||
assert starts == [0, 3]
|
||||
screen.handle("ctrl_home")
|
||||
assert screen._selected == 0
|
||||
screen.handle("ctrl_pagedown")
|
||||
assert screen._selected == starts[1] # first message of the second day
|
||||
screen.handle("ctrl_pageup")
|
||||
assert screen._selected == starts[0] # back to the first day
|
||||
|
||||
|
||||
def test_chat_channel_home_moves_the_compose_cursor() -> None:
|
||||
"""A channel's Home key edits the compose line rather than jumping the selection."""
|
||||
screen = _channel_screen(_two_day_channel_messages())
|
||||
for ch in "reply":
|
||||
screen.handle("text", ch)
|
||||
assert screen._editor.cursor == 5
|
||||
screen.handle("home")
|
||||
assert screen._editor.cursor == 0
|
||||
assert screen._selected is None # touching the compose line clears any reply selection
|
||||
|
||||
|
||||
def test_channel_self_style_keyed_on_concept_not_label() -> None:
|
||||
"""Our white 'self' style follows the message being outbound, not the 'you' label.
|
||||
|
||||
|
||||
+103
-7
@@ -19,7 +19,7 @@ from meshterm.ui.tui import frame
|
||||
from meshterm.ui.tui.progress import ProgressScreen
|
||||
from meshterm.ui.tui.prompt import AutocompleteScreen, ConfirmScreen, TextScreen
|
||||
from meshterm.ui.tui.render import render_lines, render_to_ansi
|
||||
from meshterm.ui.tui.screen import CANCEL, ScrollScreen
|
||||
from meshterm.ui.tui.screen import CANCEL, Screen, ScrollScreen
|
||||
from meshterm.ui.tui.select import Choice, SelectScreen, Separator
|
||||
from meshterm.ui.tui.session import TuiSession
|
||||
|
||||
@@ -153,17 +153,17 @@ def test_select_no_wrap_clamps_at_the_ends() -> None:
|
||||
assert _run(screen, "enter") == 3
|
||||
|
||||
|
||||
def test_select_pageup_pagedown_jump_by_a_page() -> None:
|
||||
"""PageDown/PageUp move the highlight a page at a time, clamped to the choice range."""
|
||||
from meshterm.ui.tui.select import _PAGE
|
||||
|
||||
def test_select_pageup_pagedown_jump_by_a_screenful() -> None:
|
||||
"""PageDown/PageUp move the highlight a screenful at a time, clamped to the choice range."""
|
||||
items = [Choice(f"c{i}", i) for i in range(30)]
|
||||
screen = SelectScreen("pick", items) # starts on the first choice
|
||||
screen.note_metrics(total=30, viewport=11) # a screenful is viewport - 1 = 10 rows
|
||||
screen.handle("pagedown")
|
||||
assert _run(screen, "enter") == _PAGE # advanced one page down
|
||||
assert _run(screen, "enter") == 10 # advanced one page (viewport - 1) down
|
||||
screen = SelectScreen("pick", items, default=25)
|
||||
screen.note_metrics(total=30, viewport=11)
|
||||
screen.handle("pageup")
|
||||
assert _run(screen, "enter") == 25 - _PAGE # and one page back up
|
||||
assert _run(screen, "enter") == 25 - 10 # and one page back up
|
||||
|
||||
|
||||
def _grouped_menu(default: object = None) -> SelectScreen:
|
||||
@@ -211,9 +211,87 @@ def test_select_pinned_heading_keeps_the_last_row_reachable() -> None:
|
||||
assert below is False # and we know we're at the bottom
|
||||
|
||||
|
||||
def test_screen_sticky_header_picks_the_governing_recorded_header() -> None:
|
||||
"""The base Screen.sticky_header logic is generic over any recorded headers list.
|
||||
|
||||
Both the select list and the chat transcript reuse it by populating ``_sticky_headers``;
|
||||
this exercises the shared rule directly: pin the last header at or above the offset, unless
|
||||
it *is* the top row or none sits above it.
|
||||
"""
|
||||
screen = Screen()
|
||||
screen._sticky_headers = [(0, "A"), (5, "B"), (12, "C")]
|
||||
assert screen.sticky_header(0) is None # header A is itself the top row
|
||||
assert screen.sticky_header(3) == "A" # scrolled past A, before B → A governs
|
||||
assert screen.sticky_header(5) is None # header B is now the top row
|
||||
assert screen.sticky_header(20) == "C" # below every header → the last one pins
|
||||
assert Screen().sticky_header(9) is None # no recorded headers → nothing to pin
|
||||
|
||||
|
||||
def test_select_ctrl_page_jumps_between_sections() -> None:
|
||||
"""Ctrl+PageDown lands on the next section's first choice; Ctrl+PageUp walks back up."""
|
||||
screen = _grouped_menu() # Channels (6) then Direct (8), highlight on the first choice
|
||||
screen.handle("ctrl_pagedown")
|
||||
assert _run(screen, "enter") == ("d", 0) # jumped to the first Direct choice
|
||||
screen.handle("ctrl_pageup")
|
||||
assert _run(screen, "enter") == ("c", 0) # already atop Direct → back to Channels' first
|
||||
screen.handle("ctrl_pagedown")
|
||||
assert _run(screen, "enter") == ("d", 0) # and forward to Direct again
|
||||
|
||||
|
||||
# --- scroll ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_screen_scroll_helpers_page_and_clamp_to_metrics() -> None:
|
||||
"""The shared scroll helpers page by a screenful and clamp to the recorded body/viewport."""
|
||||
screen = Screen()
|
||||
screen.note_metrics(total=100, viewport=10)
|
||||
screen.scroll_pages(1)
|
||||
assert screen.scroll == 9 # a page is viewport - 1
|
||||
screen.scroll_to_bottom()
|
||||
assert screen.scroll == 90 # total - viewport
|
||||
screen.scroll_lines(50)
|
||||
assert screen.scroll == 90 # clamped, never past the bottom
|
||||
screen.scroll_to_top()
|
||||
assert screen.scroll == 0
|
||||
|
||||
|
||||
def test_screen_section_scroll_walks_recorded_headers() -> None:
|
||||
"""Ctrl+PageUp/PageDown move the scroll offset between recorded section boundaries."""
|
||||
screen = Screen()
|
||||
screen.note_metrics(total=100, viewport=10)
|
||||
screen._sticky_headers = [(0, "A"), (20, "B"), (60, "C")]
|
||||
screen.scroll_to_next_section()
|
||||
assert screen.scroll == 20 # from the top → start of section B
|
||||
screen.scroll_to_next_section()
|
||||
assert screen.scroll == 60 # → start of C
|
||||
screen.scroll_to_next_section()
|
||||
assert screen.scroll == 90 # no section past C → clamp to the bottom
|
||||
screen.scroll = 40 # mid-section B
|
||||
screen.scroll_to_section_start()
|
||||
assert screen.scroll == 20 # up to B's start
|
||||
screen.scroll_to_section_start()
|
||||
assert screen.scroll == 0 # already atop B → previous section (A at the top)
|
||||
|
||||
|
||||
def test_scroll_screen_ctrl_edges_and_sectionless_fallback() -> None:
|
||||
"""Ctrl+Home/End reach the edges; with no sections Ctrl+PageUp/PageDown do too."""
|
||||
body = Text("\n".join(f"line {i}" for i in range(100)))
|
||||
screen = ScrollScreen(body, title="log")
|
||||
screen.render_body(40) # sets total = 100
|
||||
screen.note_viewport(10)
|
||||
screen.handle("ctrl_end")
|
||||
assert screen.scroll == 90
|
||||
screen.handle("ctrl_home")
|
||||
assert screen.scroll == 0
|
||||
screen.handle("ctrl_pagedown") # no sections recorded → falls through to the bottom
|
||||
assert screen.scroll == 90
|
||||
screen.handle("ctrl_pageup")
|
||||
assert screen.scroll == 0
|
||||
|
||||
|
||||
# --- scroll (existing) -------------------------------------------------------
|
||||
|
||||
|
||||
def test_scroll_screen_paging_and_clamp() -> None:
|
||||
"""PageDown advances by a page, End jumps to the bottom, both clamped to content."""
|
||||
body = Text("\n".join(f"line {i}" for i in range(100)))
|
||||
@@ -308,6 +386,24 @@ def test_text_screen_edits_and_validates() -> None:
|
||||
assert _run(screen, "enter") == "ok"
|
||||
|
||||
|
||||
def test_line_editor_word_motion() -> None:
|
||||
"""Ctrl+Left/Right hop by word — to the current word's start, else the previous/next."""
|
||||
from meshterm.ui.tui.prompt import _LineEditor
|
||||
|
||||
editor = _LineEditor("the quick brown fox") # cursor at the end (len 20)
|
||||
editor.edit("ctrl_left")
|
||||
assert editor.cursor == 17 # start of "fox"
|
||||
editor.edit("ctrl_left")
|
||||
assert editor.cursor == 11 # skips the double space → start of "brown"
|
||||
editor.edit("ctrl_left")
|
||||
assert editor.cursor == 4 # start of "quick"
|
||||
editor.edit("ctrl_right")
|
||||
assert editor.cursor == 11 # forward over "quick" and the spaces → start of "brown"
|
||||
editor.cursor = 13 # mid-"brown"
|
||||
editor.edit("ctrl_left")
|
||||
assert editor.cursor == 11 # to the current word's start, not the previous word
|
||||
|
||||
|
||||
def test_text_screen_password_masks() -> None:
|
||||
"""A password field renders bullets, not the typed characters."""
|
||||
screen = TextScreen("pw?", password=True)
|
||||
|
||||
Reference in new issue
Block a user