Message paths laid its whole body out as one strip — quote, fan, captions, then every arrival — and let the frame scroll it under the selection. Walk far enough down a busy message and the picture the routes are being compared against slides off the top of the dialog, which is the one thing the reader was looking at (JP, 2026-09-04). The head is pinned now and only the list moves, the app-wide windowed-list pattern the node detail's Routes tab already runs: the quote and the fan take their lines, the arrivals window into what is left with faint `↑ n more` / `↓ n more` edge markers, and the fan compresses its own lane pitch before the list would lose its window — floored at two arrivals and a marker, because comparing one arrival against another is what this dialog is for. PgUp/PgDn now page the selection by a windowful of arrivals rather than by body lines, and both they and the F-lane's chips say so only while there is more than one arrival to move between. The variable-height fit both screens need moves to `ListWindow.fit_blocks`, beside the single-line `fit` it is the sibling of; node detail drops its own copy and its hand-kept top/page state for the shared window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
706 lines
33 KiB
Python
706 lines
33 KiB
Python
"""The screen abstraction: one layer in the TUI's window stack.
|
||
|
||
A :class:`Screen` knows how to render its body (as Rich, converted to ANSI lines by
|
||
:mod:`~meshterm.ui.tui.render`) and how to react to normalized key *actions* dispatched by
|
||
the :class:`~meshterm.ui.tui.session.TuiSession`. Screens never touch prompt_toolkit or the
|
||
terminal directly, so they stay small, uniform, and testable.
|
||
|
||
Interactive screens (select, text, confirm) resolve an :class:`asyncio.Future` when the user
|
||
commits or cancels; the session awaits that future and then pops the screen, giving the
|
||
push/await/pop model behind ``await session.select(...)`` and friends.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from collections.abc import Sequence as _SequenceABC
|
||
from typing import Any, Callable, Optional, Sequence, Union
|
||
|
||
from rich.console import RenderableType
|
||
from rich.text import Text
|
||
|
||
from .render import render_lines
|
||
from .spinner import Spinner
|
||
|
||
#: Sentinel a screen resolves with when the user presses Esc to cancel, distinct from a
|
||
#: legitimately selected ``None`` value.
|
||
CANCEL = object()
|
||
|
||
#: Sentinel the session resolves the top screen's future with when the pop-all key is
|
||
#: pressed (^W). It is never a legitimate result: every navigation boundary that sees it
|
||
#: turns it straight into :class:`PopToMenu`, so no caller ever has to test for it.
|
||
POP_ALL = object()
|
||
|
||
|
||
class PopToMenu(BaseException):
|
||
"""Raised through every awaiting navigation frame to unwind to the main menu.
|
||
|
||
The whole realized path is a stack of pending ``await`` expressions — a screen's caller
|
||
awaiting its result, that caller's caller awaiting *it* — so the one way to leave every
|
||
frame at once is to raise through them all. Each frame pops its own screen in a ``finally`` on
|
||
the way out, which is exactly the strict single-pop discipline applied N times: no
|
||
screen is ever dropped without its owner unwinding.
|
||
|
||
It derives from :class:`BaseException`, not :class:`Exception`, for the same reason
|
||
:class:`asyncio.CancelledError` does: the app is full of ``except Exception`` guards
|
||
that stop a misbehaving tool from taking down the menu
|
||
(:func:`~meshterm.ui.menu._run_selection` is the widest), and an unwind must pass
|
||
through them rather than be swallowed and reported as a tool failure. ``finally``
|
||
blocks still run, so nothing leaks.
|
||
|
||
Caught in exactly one place — the main menu loop, which is the unwind's destination.
|
||
"""
|
||
|
||
|
||
class Screen:
|
||
"""Base class for every TUI layer.
|
||
|
||
Attributes:
|
||
title: Heading shown at the top of the screen/dialog.
|
||
footer_hint: One-line key hint shown in the footer.
|
||
scroll: Current vertical scroll offset into the body's rendered lines.
|
||
future: Resolved with the screen's result (or :data:`CANCEL`) when it commits.
|
||
floating: Whether the session should draw this screen as a centered dialog over
|
||
the dimmed screen beneath it (deeper layers float; the base does not).
|
||
modal: Whether this layer is a *dialog* — a prompt or a running operation that
|
||
owns the keyboard until it is answered or finishes. Distinct from
|
||
:attr:`floating`, which is only about how the layer is drawn: an ordinary
|
||
select list floats as a box and is not modal, while a progress dialog is
|
||
modal and a full-frame busy splash is modal without floating at all. The
|
||
pop-all key (^W) refuses to fire while a modal layer is on top, so an unwind
|
||
can never blow past an unanswered question or yank the stack out from under
|
||
work in flight.
|
||
grow_only: Whether a floating dialog may only ever grow. Its box is sized to the
|
||
tallest body — and, for a natural-width dialog, the widest — it has shown, not
|
||
the current one, so a screen whose content swings as it changes (the packet
|
||
viewer paging between packets, the path composer's shifting suggestions) keeps
|
||
a steady, centred box — blank-padded when the current body is shorter — instead
|
||
of resizing on every change.
|
||
chrome: Whether, as the base screen, this layer is wrapped in the session's
|
||
persistent header/footer frame. A startup splash sets this ``False`` so the
|
||
session instead centers it under the :attr:`banner` with no status bars.
|
||
banner: Block-glyph art (one string per row) drawn, centered, above a chromeless
|
||
base screen. Ignored while ``chrome`` is ``True``.
|
||
footnote: A short line drawn muted and centered *below* a chromeless base screen's
|
||
box (e.g. a copyright notice). Ignored while ``chrome`` is ``True``.
|
||
"""
|
||
|
||
title: str = ""
|
||
footer_hint: str = "Esc back"
|
||
floating: bool = True
|
||
modal: bool = False
|
||
grow_only: bool = False
|
||
chrome: bool = True
|
||
banner: Optional[Sequence[str]] = None
|
||
footnote: Optional[str] = None
|
||
|
||
@property
|
||
def fkey_lane(self):
|
||
"""The screen's F-key lane (the PicoCalc footer; see :mod:`~meshterm.ui.tui.fkeys`).
|
||
|
||
Five slots, F1–F5, each with an optional F6–F10 Shift-bank companion. A slot is
|
||
for functionality that is otherwise hard to reach — Enter/Esc never appear, since
|
||
both keys are already close at hand. The default puts paging (no physical key at
|
||
all) on the prime F4/F5 pair and the jump-to-top/end pair behind Shift on those
|
||
same two keys, since Home and End *are* on this keyboard and only ever wanted a
|
||
chip for consistency with the pager they belong to. F1–F3 are left free: they are
|
||
where a screen's own verbs go, and a screen with none leaves them blank. A screen
|
||
overrides this property for its own verbs; the lane may differ screen to screen.
|
||
|
||
Read fresh every paint, so an override dims the slots whose action would do
|
||
nothing right now rather than advertising a dead key — the lane's standing rule
|
||
(see :mod:`~meshterm.ui.tui.fkeys`). Screens whose nav keys only scroll gate the
|
||
shared slots on :attr:`content_overflows`; this base can't assume that much,
|
||
since a nav key elsewhere moves a cursor, a pick, or the map's zoom.
|
||
"""
|
||
from .fkeys import DEFAULT_LANE
|
||
|
||
return DEFAULT_LANE
|
||
|
||
def __init__(self) -> None:
|
||
"""Initialize scroll state and the (later-assigned) result future."""
|
||
self.scroll = 0
|
||
self.future: Optional[asyncio.Future] = None
|
||
# (body-line index, rendered ANSI lines) for each section landmark eligible to be
|
||
# pinned to the top rows once it scrolls off. A block is usually one line — a section
|
||
# heading, a day divider — but may carry the rows that belong with it (a heading and
|
||
# the description under it), which pin together as they scroll off, in order. A screen
|
||
# that wants sticky headers rebuilds this list while rendering its body (see
|
||
# :meth:`sticky_block`); the default is no landmarks.
|
||
self._sticky_headers: list[tuple[int, list[str]]] = []
|
||
# The one header that pins for the *whole* list rather than for its section — a
|
||
# table's column header, which means nothing scrolled off (see :meth:`sticky_rows`).
|
||
# Recorded the same way, as (body-line index, rendered line); ``None`` for none.
|
||
self._pinned_header: Optional[tuple[int, str]] = None
|
||
# 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
|
||
# A grow-only screen's high-water body height and natural width: the tallest and
|
||
# widest it has rendered into a dialog, so its box can be held at that size once
|
||
# reached (see :meth:`ratchet_viewport` / :meth:`ratchet_width`). Zero until the
|
||
# first paint; irrelevant while not :attr:`grow_only`.
|
||
self._viewport_floor = 0
|
||
self._width_floor = 0
|
||
|
||
# --- rendering -----------------------------------------------------------
|
||
|
||
def render_body(self, width: int) -> list[str]:
|
||
"""Return the body's ANSI lines at ``width`` columns (unclipped).
|
||
|
||
Args:
|
||
width: Inner content width in columns.
|
||
|
||
Returns:
|
||
One ANSI string per body line; the session slices these to the viewport.
|
||
"""
|
||
raise NotImplementedError
|
||
|
||
def ratchet_viewport(self, body_h: int) -> int:
|
||
"""The body height a :attr:`grow_only` dialog is sized to: its running maximum.
|
||
|
||
The frame calls this each paint with the current body's height. A grow-only
|
||
screen returns the tallest height it has yet shown — so its box never shrinks,
|
||
and the frame blank-pads a now-shorter body to fill it — while an ordinary
|
||
screen returns the height unchanged and is sized to each body as it comes.
|
||
"""
|
||
if not self.grow_only:
|
||
return body_h
|
||
self._viewport_floor = max(self._viewport_floor, body_h)
|
||
return self._viewport_floor
|
||
|
||
def ratchet_width(self, natural: int) -> int:
|
||
"""The natural width a :attr:`grow_only` dialog is sized to: its running maximum.
|
||
|
||
The width-side twin of :meth:`ratchet_viewport`, applied by the frame to a
|
||
dialog's requested ``dialog_width`` before the terminal cap — so a grow-only
|
||
box holds its widest extent as its content narrows, but a shrinking terminal
|
||
still clips it. Full-cap dialogs (no ``dialog_width``) never reach here.
|
||
"""
|
||
if not self.grow_only:
|
||
return natural
|
||
self._width_floor = max(self._width_floor, natural)
|
||
return self._width_floor
|
||
|
||
def cursor_line(self) -> Optional[int]:
|
||
"""Return a body line that must stay visible, or ``None`` for free scrolling.
|
||
|
||
Selection/text screens return the active row so the session can keep it in view;
|
||
plain scroll screens return ``None``.
|
||
"""
|
||
return None
|
||
|
||
def consume_edge_scrub(self) -> int:
|
||
"""Right-edge columns the session should force-repaint on the next paint (0 = none).
|
||
|
||
The default is 0 — no screen needs this. A screen whose body can emit glyphs the
|
||
terminal renders at an unexpected width (the map's braille) overrides this to have the
|
||
session redraw just the smeared edge cells, which prompt_toolkit's differential paint
|
||
would otherwise never rewrite (see :class:`~meshterm.ui.map_screen.MapScreen`).
|
||
"""
|
||
return 0
|
||
|
||
def sticky_block(self, scroll: int) -> list[str]:
|
||
"""The already-rendered body lines to pin as ``scroll`` moves past a section landmark.
|
||
|
||
Lets a grouped list keep its current section overhead after the section's own rows have
|
||
scrolled off — a select screen's ``Channels``/``Direct`` divider, the Trophy case's
|
||
discipline heading *and the description under it*, the chat transcript's
|
||
``── Wed Jul 8 ──`` day divider. ``scroll`` is the offset the body is about to be
|
||
sliced at; the returned lines are drawn as top rows, under any whole-list header pinned
|
||
over them (:meth:`sticky_rows` composes them for the frame).
|
||
|
||
The shared rule: among the blocks a screen recorded in :attr:`_sticky_headers` while
|
||
rendering, find the last one starting at or above ``scroll`` (the one *governing* the
|
||
top visible row) and pin exactly the rows of it that ``scroll`` has passed. So a block
|
||
hands its rows over one at a time as they leave — the heading pins while its
|
||
description is still the top content row, and the two pin together once both are gone —
|
||
and the pinned rows always continue seamlessly into the body below. A screen opts in
|
||
simply by populating :attr:`_sticky_headers`; the empty default means no pinning.
|
||
|
||
A block never takes more than half the viewport: the rows are given up from the *end*,
|
||
so the heading — the row that says which section this is — is the last thing dropped on
|
||
a short terminal.
|
||
"""
|
||
governing: Optional[tuple[int, list[str]]] = None
|
||
for entry in self._sticky_headers:
|
||
if entry[0] <= scroll:
|
||
governing = entry
|
||
else:
|
||
break # blocks are recorded in body order; nothing past here can govern
|
||
if governing is None:
|
||
return [] # no landmark above the top visible row
|
||
at, rows = governing
|
||
keep = max(1, self._scroll_viewport // 2)
|
||
return rows[: min(scroll - at, keep)]
|
||
|
||
def sticky_rows(self, scroll: int) -> list[str]:
|
||
"""Every already-rendered line to pin above the body sliced at ``scroll``.
|
||
|
||
What the frame actually draws, top-down: the whole-list header from
|
||
:attr:`_pinned_header` (a table's column header — the lane names, which mean the
|
||
same everywhere in the list) once it has scrolled off, then the governing section
|
||
block from :meth:`sticky_block`. A screen with only section headings pins one
|
||
line; a grouped table pins both, so a scrolled row can still be read off its lanes
|
||
*and* placed in its group.
|
||
|
||
Args:
|
||
scroll: The offset the body is about to be sliced at.
|
||
|
||
Returns:
|
||
The lines to pin, in draw order (empty for nothing to pin). Each one costs the
|
||
body a row, so the frame reserves exactly as many as come back.
|
||
"""
|
||
rows: list[str] = []
|
||
if self._pinned_header is not None and self._pinned_header[0] < scroll:
|
||
rows.append(self._pinned_header[1])
|
||
rows.extend(self.sticky_block(scroll))
|
||
return rows
|
||
|
||
# --- input ---------------------------------------------------------------
|
||
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
"""React to a normalized key action from the session.
|
||
|
||
Args:
|
||
action: One of ``up``, ``down``, ``pageup``, ``pagedown``, ``home``, ``end``,
|
||
``ctrl_home``, ``ctrl_end``, ``ctrl_pageup``, ``ctrl_pagedown``, ``left``,
|
||
``right``, ``ctrl_left``, ``ctrl_right``, ``ctrl_up``, ``ctrl_down``,
|
||
``shift_up``, ``shift_down``,
|
||
``shift_left``, ``shift_right``, ``enter``, ``escape``, ``backspace``,
|
||
``delete``, ``space``, ``tab``, ``shift_tab``, or ``text`` (with ``data``
|
||
set to the character).
|
||
data: The typed character when ``action`` is ``text``.
|
||
"""
|
||
if action == "escape":
|
||
self.resolve(CANCEL)
|
||
|
||
def resolve(self, value: Any) -> None:
|
||
"""Resolve this screen's future, committing ``value`` (or :data:`CANCEL`).
|
||
|
||
Args:
|
||
value: The result to hand back to the awaiting caller.
|
||
"""
|
||
if self.future is not None and not self.future.done():
|
||
self.future.set_result(value)
|
||
|
||
# --- scrolling helpers (shared) ------------------------------------------
|
||
|
||
def note_metrics(self, total: int, viewport: int) -> None:
|
||
"""Record the last render's body height and viewport (the frame calls this each paint).
|
||
|
||
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)
|
||
|
||
@property
|
||
def content_overflows(self) -> bool:
|
||
"""Whether the last-rendered body is taller than its viewport — i.e. it can scroll.
|
||
|
||
The reusable gate for the app-wide rule that a footer only advertises a key that
|
||
would do something: a screen whose body fits has nothing to scroll, so it drops its
|
||
``↑↓ PgUp/PgDn scroll`` atom (see :attr:`DashboardScreen.footer_hint`). Reads the
|
||
metrics the frame recorded on the *previous* paint (:meth:`note_metrics`), so it is
|
||
honest one frame after a resize — the frame repaints again immediately.
|
||
"""
|
||
return self._scroll_total > self._scroll_viewport
|
||
|
||
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)
|
||
|
||
|
||
class LazyLines(_SequenceABC):
|
||
"""A body's ANSI lines, each rendered the first time something actually reads it.
|
||
|
||
A screen renders its body *whole* and the frame then slices a viewport out of it — so a
|
||
141-contact list rasterizes 150 rows to show the twenty that fit, on every keystroke and
|
||
on every idle tick. The lines outside the slice are never looked at; they are built and
|
||
thrown away.
|
||
|
||
This is the same list, deferred. A screen hands over one entry per body line — either the
|
||
finished string (for the few lines it had to render anyway: separators, headings, the
|
||
query echo) or a callable that will render it — and only the entries the frame indexes
|
||
are ever called. It is a plain :class:`~collections.abc.Sequence`, so ``len()``, slicing,
|
||
iteration and ``==`` against a list all behave exactly as the list it replaces; a slice
|
||
materializes to a real list of strings, which is what the frame goes on to pad and frame.
|
||
|
||
Two properties make it safe to substitute:
|
||
|
||
* **The line count is exact and immediate.** Deferring the *drawing* of a row never
|
||
defers *how many rows there are* — the screen counts them while it plans — so the
|
||
scroll clamp, the ``↑↓ more`` markers and the sticky-header offsets are unchanged.
|
||
* **A line is rendered at most once.** Results are memoized per index, so the frame
|
||
re-reading a row (a pinned header, a re-settled slice) costs nothing extra.
|
||
|
||
A row's content is resolved inside its callable, which means a :class:`Choice` with a
|
||
live callable title is resolved only while it is on screen — the same rows a person can
|
||
actually see changing.
|
||
"""
|
||
|
||
__slots__ = ("_entries", "_drawn")
|
||
|
||
def __init__(self, entries: list[Union[str, Callable[[], str]]]) -> None:
|
||
"""Wrap one entry per body line: a rendered string, or a callable rendering it."""
|
||
self._entries = entries
|
||
self._drawn: dict[int, str] = {}
|
||
|
||
def __len__(self) -> int:
|
||
"""The body's height in lines — known without rendering any of them."""
|
||
return len(self._entries)
|
||
|
||
def __getitem__(self, index): # type: ignore[no-untyped-def]
|
||
"""Return line ``index`` (or a real list of lines for a slice), rendering on demand."""
|
||
if isinstance(index, slice):
|
||
return [self[i] for i in range(*index.indices(len(self._entries)))]
|
||
if index < 0:
|
||
index += len(self._entries)
|
||
if not 0 <= index < len(self._entries):
|
||
raise IndexError(index)
|
||
line = self._drawn.get(index)
|
||
if line is None:
|
||
entry = self._entries[index]
|
||
line = entry if isinstance(entry, str) else entry()
|
||
self._drawn[index] = line
|
||
return line
|
||
|
||
def __eq__(self, other: object) -> bool:
|
||
"""Compare equal to the plain list of lines this stands in for."""
|
||
if isinstance(other, (LazyLines, list)):
|
||
return list(self) == list(other)
|
||
return NotImplemented
|
||
|
||
def __repr__(self) -> str:
|
||
"""Describe the height and how much of it has actually been drawn."""
|
||
return f"LazyLines({len(self._entries)} lines, {len(self._drawn)} drawn)"
|
||
|
||
|
||
class ListWindow:
|
||
"""Scroll state for a list that scrolls *inside* a fixed screen.
|
||
|
||
The app-wide windowed-list pattern (established by the mesh walk's link list): the
|
||
screen sizes its fixed chrome — headings, charts, action rows — to the frame's
|
||
viewport (see :meth:`Screen.note_viewport`; the frame records it before
|
||
``render_body`` runs) and hands the leftover rows here; only the list's rows
|
||
scroll within them. Faint ``↑ n more`` / ``↓ n more`` markers (see
|
||
:meth:`marker`) take the window's edge rows exactly when rows are hidden on
|
||
that side, and the settled content capacity is remembered as :attr:`page`, the
|
||
stride a PgUp/PgDn should move by.
|
||
|
||
Attributes:
|
||
top: First list row the window shows (clamped by :meth:`fit` each paint).
|
||
page: Rows the window carried on the last :meth:`fit` — the paging stride.
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
"""Start at the top with a conservative pre-first-paint stride."""
|
||
self.top = 0
|
||
self.page = 6
|
||
|
||
def fit(self, n: int, win: int, index: Optional[int] = None) -> tuple[int, int]:
|
||
"""Settle the window over ``n`` rows into ``win`` lines: ``(top, count)``.
|
||
|
||
The edge markers eat the window's boundary rows exactly when rows hide
|
||
beyond them, so the content capacity shifts as the window slides; a few
|
||
passes settle top, capacity, and the highlight clamp together.
|
||
|
||
Args:
|
||
n: Total list rows.
|
||
win: Lines the window may spend — on content and markers alike.
|
||
index: A highlighted row that must stay visible, or ``None`` when the
|
||
window scrolls free (a cursor-less log; ``top`` is just clamped).
|
||
|
||
Returns:
|
||
The first visible row and how many rows to draw. Rows hidden above
|
||
number ``top``; below, ``n - top - count`` — each side non-zero exactly
|
||
when its marker row should be drawn.
|
||
"""
|
||
if n <= win:
|
||
self.top = 0
|
||
self.page = max(1, win)
|
||
return 0, n
|
||
top = max(0, min(self.top, n - 1))
|
||
count = 1
|
||
for _ in range(4):
|
||
above = 1 if top > 0 else 0
|
||
below = 1 if n - top > win - above else 0
|
||
count = max(1, win - above - below)
|
||
if index is not None:
|
||
if index < top:
|
||
top = index
|
||
elif index >= top + count:
|
||
top = index - count + 1
|
||
top = max(0, min(top, n - count))
|
||
self.top = top
|
||
self.page = count
|
||
return top, count
|
||
|
||
def fit_blocks(
|
||
self, heights: list[int], win: int, index: Optional[int] = None
|
||
) -> tuple[int, int]:
|
||
"""Settle the window over variable-height rows: ``(top, count)`` blocks to draw.
|
||
|
||
The hanging-wrap sibling of :meth:`fit`, for a list whose rows are *blocks* of
|
||
rendered lines — a route row and the muted facts under it, a wrapped path line —
|
||
rather than one line each. A block is never split across the window's edge, the
|
||
faint edge markers eat a window line exactly when rows hide beyond them, and the
|
||
highlighted block is walked into view.
|
||
|
||
Args:
|
||
heights: Rendered line count per list row.
|
||
win: Lines the window may spend — on content and markers alike.
|
||
index: A highlighted row that must stay visible, or ``None`` when the window
|
||
scrolls free (``top`` is then just clamped).
|
||
|
||
Returns:
|
||
The first visible row and how many rows to draw, as :meth:`fit` returns them.
|
||
"""
|
||
n = len(heights)
|
||
if sum(heights) <= win:
|
||
self.top = 0
|
||
self.page = max(1, n)
|
||
return 0, n
|
||
top = max(0, min(self.top, n - 1))
|
||
if index is not None:
|
||
top = min(top, index)
|
||
while True:
|
||
above = 1 if top > 0 else 0
|
||
count = _fill(heights, top, win - above)
|
||
if top + count < n: # rows hide below: the marker takes one of the lines
|
||
count = max(1, _fill(heights, top, win - above - 1))
|
||
if index is None or index < top + count:
|
||
break
|
||
top += 1 # walk the window down until the cursor's row is inside
|
||
self.top = top
|
||
self.page = max(1, count)
|
||
return top, count
|
||
|
||
def to_end(self) -> None:
|
||
"""Slide the window to the list's tail on the next :meth:`fit`.
|
||
|
||
The overshoot is deliberate — the caller rarely knows the list length at
|
||
keypress time, and :meth:`fit` clamps to the last windowful either way.
|
||
"""
|
||
self.top = 10**9
|
||
|
||
@staticmethod
|
||
def marker(hidden: int, direction: str) -> Text:
|
||
"""The faint edge row counting rows hidden ``"above"`` or ``"below"``."""
|
||
arrow = "↑" if direction == "above" else "↓"
|
||
return Text(f" {arrow} {hidden} more", style="faint")
|
||
|
||
|
||
def _fill(heights: list[int], top: int, budget: int) -> int:
|
||
"""How many whole blocks from ``top`` fit into ``budget`` lines (greedy, in order)."""
|
||
used = 0
|
||
count = 0
|
||
for h in heights[top:]:
|
||
if used + h > budget:
|
||
break
|
||
used += h
|
||
count += 1
|
||
return count
|
||
|
||
|
||
def _clamp_scroll(scroll: int, total: int, viewport: int) -> int:
|
||
"""Clamp a scroll offset to the valid range for the content and viewport.
|
||
|
||
Args:
|
||
scroll: Proposed offset.
|
||
total: Total line count.
|
||
viewport: Visible height in lines.
|
||
|
||
Returns:
|
||
The offset clamped to ``0 .. max(0, total - viewport)``.
|
||
"""
|
||
return max(0, min(scroll, max(0, total - viewport)))
|
||
|
||
|
||
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 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.
|
||
"""
|
||
|
||
@property
|
||
def fkey_lane(self):
|
||
"""The shared lane, its nav slots dimmed when the content already fits.
|
||
|
||
Every nav key here scrolls and nothing else, so a result window short enough to
|
||
read whole has four inert slots — the same gate the footer hint uses on the other
|
||
platform (:attr:`Screen.content_overflows`).
|
||
"""
|
||
from .fkeys import default_lane
|
||
|
||
return default_lane(nav=self.content_overflows)
|
||
|
||
def __init__(
|
||
self,
|
||
renderable: RenderableType,
|
||
*,
|
||
title: str = "",
|
||
footer_hint: Optional[str] = None,
|
||
floating: bool = True,
|
||
) -> None:
|
||
"""Wrap a renderable in a dismissable, scrollable screen.
|
||
|
||
Args:
|
||
renderable: The Rich content to display.
|
||
title: Heading for the screen/dialog.
|
||
footer_hint: Footer key hint, or ``None`` to derive it (see
|
||
:attr:`footer_hint`).
|
||
floating: Whether to draw as a centered dialog over the parent.
|
||
"""
|
||
super().__init__()
|
||
self._renderable = renderable
|
||
self.title = title
|
||
self._footer_hint = footer_hint
|
||
self.floating = floating
|
||
|
||
@property
|
||
def footer_hint(self) -> str: # type: ignore[override]
|
||
"""The footer keys — whatever the caller passed, or the honest default.
|
||
|
||
A read-only body has only two keys to name, the pager and Esc, so the hint can
|
||
be derived rather than spelled out at each call site — and derived, it keeps the
|
||
standing rule that a footer never advertises a key that would do nothing: the
|
||
scroll atom appears only while the body is actually taller than its viewport
|
||
(:attr:`Screen.content_overflows`). The Esc verb follows the surface the standards
|
||
assign it: a floating read-only view *closes*, a full screen goes *back*.
|
||
"""
|
||
if self._footer_hint is not None:
|
||
return self._footer_hint
|
||
verb = "Esc close" if self.floating else "Esc back"
|
||
return f"↑↓ PgUp/PgDn scroll · {verb}" if self.content_overflows else verb
|
||
|
||
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._scroll_total = max(1, len(lines))
|
||
return lines
|
||
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
"""Scroll the viewport (by line, page, or to an edge) or dismiss the screen."""
|
||
if action == "up":
|
||
self.scroll_lines(-1)
|
||
elif action == "down":
|
||
self.scroll_lines(1)
|
||
elif action == "pageup":
|
||
self.scroll_pages(-1)
|
||
elif action in ("pagedown", "space"):
|
||
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)
|
||
|
||
|
||
class BusyScreen(Screen):
|
||
"""A non-interactive splash body: an animated spinner beside a message.
|
||
|
||
Used to keep the startup splash on screen — its wordmark and box unchanged — while a
|
||
short async task runs (e.g. smoke-testing a chosen companion device). It set as a
|
||
chromeless base so it redraws the same centered-under-the-wordmark splash, only swapping
|
||
the box's contents. It resolves nothing and ignores every key; the session pops it when
|
||
the awaited task completes. The animation itself is the reusable
|
||
:class:`~meshterm.ui.tui.spinner.Spinner`.
|
||
"""
|
||
|
||
footer_hint = "working…"
|
||
floating = False
|
||
modal = True # work in flight owns the keyboard; ^W must not unwind out from under it
|
||
|
||
@property
|
||
def fkey_lane(self):
|
||
"""No lane at all: this screen swallows every key, so none of them do anything."""
|
||
from .fkeys import EMPTY_LANE
|
||
|
||
return EMPTY_LANE
|
||
|
||
def __init__(self, message: str, *, title: str = "") -> None:
|
||
"""Start a busy splash showing ``message`` under an optional ``title``."""
|
||
super().__init__()
|
||
self.title = title
|
||
self._message = message
|
||
self._spinner = Spinner()
|
||
|
||
def tick(self) -> None:
|
||
"""Advance the spinner to its next frame (driven by the session's animation timer)."""
|
||
self._spinner.tick()
|
||
|
||
def render_body(self, width: int) -> list[str]:
|
||
"""Render the current spinner frame followed by the message."""
|
||
line = self._spinner.text()
|
||
line.append(" ")
|
||
line.append(self._message)
|
||
return render_lines(line, width)
|
||
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
"""Swallow all keys: the splash dismisses itself when the task finishes."""
|
||
return
|