Files
Jean-Pierre MartineauandClaude Haiku 5.5 60a2df727a Shift+Delete in the archive preview locks a contact as well as sparing it
Delete keeps a contact out of this sweep only. Shift+Delete removes the row
the same way and stages a lock, so no later sweep takes the contact. The
locks are written on Apply with the archive; Esc and Back ask before they
discard them. A preview left with only locks applies them without the amber
confirm.

The footer names ⇧Del lock only when the hint still fits 72 cells, and a
contact row now says Enter open, since Enter opens its node page. On the
handhelds, Lock rides Shift+F3 behind the Delete chip.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 18:14:39 -04:00

1079 lines
54 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""The screen abstraction: one layer in the screen stack of the TUI.
A :class:`Screen` knows how to render its body (as Rich, which
:mod:`~meshterm.ui.tui.render` changes into ANSI lines). It also knows how to respond to
the normalized key *actions* that the :class:`~meshterm.ui.tui.session.TuiSession`
dispatches to it. A screen never uses prompt_toolkit or the terminal directly. Thus the
screens stay small, uniform, and easy to test.
An interactive screen (select, text, confirm) resolves an :class:`asyncio.Future` when the
user commits or cancels. The session awaits that future and then pops the screen. This is
the push/await/pop model of ``await session.select(...)`` and the related calls.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable, Sequence
from collections.abc import Sequence as _SequenceABC
from typing import Any
from rich.cells import cell_len
from rich.console import RenderableType
from rich.text import Text
from .render import render_hanging, render_lines
from .spinner import Spinner
#: The sentinel that a screen resolves with when the user presses Esc to cancel. It is
#: different from a ``None`` value that the user selected.
CANCEL = object()
#: The sentinel that the session resolves the future of the top screen with, when the user
#: presses the pop-all key (^W). It is never a valid result: each navigation boundary that
#: gets it changes it immediately into :class:`PopToMenu`. Thus a caller never has to test
#: for it.
POP_ALL = object()
#: The keys of the edge scroll (refer to :meth:`Screen.edge_scroll`). They are the arrows
#: and the direction of each step, the jumps to the two ends, and the keys that act on the
#: highlight. When the highlight is off the screen, a key of this last group (and the arrow
#: that points at the highlight) first makes the highlight visible again, before it does
#: anything.
_EDGE_STEPS = {"up": -1, "down": 1}
_EDGE_ENDS = {"home": -1, "ctrl_home": -1, "end": 1, "ctrl_end": 1}
_SNAP_BACK_ACTIONS = frozenset({"enter", "delete", "shift_delete"})
class PopToMenu(BaseException):
"""Raised through each navigation call that awaits a screen, to unwind to the main menu.
The full path that the user opened is a stack of pending ``await`` expressions. The
caller of a screen awaits its result, and the caller of that caller awaits the caller in
turn. Thus the only way to leave all the screens on the stack at the same time is to
raise through all of these expressions. On the way out, each caller pops its own screen
in a ``finally``. This is the strict rule of one pop for each push, applied N times: a
screen is never removed unless its owner unwinds.
It derives from :class:`BaseException`, not :class:`Exception`, for the same reason as
:class:`asyncio.CancelledError`. The app has many ``except Exception`` guards. These
guards stop a tool that has a fault before it can stop the menu
(:func:`~meshterm.ui.menu._run_selection` has the widest guard). An unwind must go
through these guards. They must not catch it and report it as a failure of a tool.
``finally`` blocks still run, so nothing leaks.
Only one place catches it: the main menu loop, where the unwind ends.
"""
class Screen:
"""Base class for each TUI layer.
Attributes:
title: The heading at the top of the screen or the dialog.
short_title: The short heading that the frame draws instead of :attr:`title` when
the full heading does not fit in its rule (refer to
:func:`~meshterm.ui.tui.frame.fitted_title`). It has the same atoms in fewer
cells. Thus a narrow frame still shows each figure, instead of a title that is
cut in the middle of a word. Empty (the default) means that the title has no
shorter form.
footer_hint: The key hint of one line in the footer.
scroll: The current vertical scroll offset in the rendered lines of the body.
future: Resolved with the result of the screen (or :data:`CANCEL`) when the screen
commits.
floating: Whether the session draws this screen as a dialog at the centre, over the
dimmed screen below it (deeper layers float, the base layer does not).
modal: Whether this layer owns the keyboard. A modal layer is a prompt, or an
operation in progress, that owns the keyboard until the user answers it or it
finishes. This is different from :attr:`floating`, which is only about how the
layer is drawn. A usual select list floats as a box and is not modal. A
progress dialog is modal. A full-frame busy splash is modal and does not float.
The pop-all key (^W) does not operate while a modal layer is on top. Thus an
unwind can never go past a question that has no answer, or remove the stack
from under work in progress.
grow_only: Whether a floating dialog can only grow. The size of its box is the
size of the tallest body that it showed (and, for a dialog with a natural
width, the widest), not of the current body. Thus a screen whose content
changes size (the packet viewer when it pages between packets, the suggestions
of the path composer, which move) keeps a steady box at the centre, instead of
a box that changes size at each change. Blank lines fill the box when the
current body is shorter.
chrome: Whether this layer, as the base screen, is wrapped in the persistent header
and footer frame of the session. A startup splash sets this attribute to
``False``. Then the session puts the splash at the centre, under the
:attr:`banner`, with no status bars.
bare: Whether this layer, as the base screen, is the full frame: its body on blank
rows, with no header, no footer, no title bar, no box, and no wordmark. It is
for a screen that shows one thing for a camera or a user to look at (the QR
code of the share screen, refer to :func:`~meshterm.ui.qr.share_screen`). The
screen shows no keys: Esc leaves, and each keyboard has an Esc key. This
attribute is read before :attr:`chrome`. A bare screen never floats.
banner: Block-glyph art (one string for each line), drawn at the centre above a
base screen that has no chrome. Ignored while ``chrome`` is ``True``.
footnote: A short line, drawn muted and at the centre, below the box of a base
screen that has no chrome (for example, a copyright notice). Ignored while
``chrome`` is ``True``.
bottom_caption: A short credit or label, set right-justified into the **bottom
border rule**, in the same way as :attr:`title` is in the top rule. It is for
something that belongs to the picture in the frame, not to a row in it. The
OpenStreetMap credit of the map is the reason that it exists (refer to
:mod:`meshterm.ui.attribution`). A frame with a border already draws that
rule, so a caption in it takes no space from the body. It is read again at
each paint, so a screen can change it when its state changes. It is drawn only
where the frame of the platform has a bottom rule. The borderless frame has
none, and a screen that wants a mark there puts it in its own body instead.
flush: Whether the body of this base screen, in a frame with a border, goes all the
way to the side borders of the panel, instead of one blank cell in from each
border. The padding is space between a border and a line of text. A picture
that fills the frame (the map) has no text edge to keep away from the border.
Without this attribute, the picture loses those two cells of drawing for no
reason (JP, 2026-10-01). It has no effect where the frame of the platform has
no border, because the body already has the full width there.
"""
title: str = ""
short_title: str = ""
footer_hint: str = "Esc back"
bottom_caption: str = ""
flush: bool = False
floating: bool = True
modal: bool = False
grow_only: bool = False
chrome: bool = True
bare: bool = False
banner: Sequence[str] | None = None
footnote: str | None = None
#: Whether the page has edge scroll (refer to :meth:`edge_scroll`). It is on for each
#: screen. It does nothing on a screen whose page has no highlight (:meth:`cursor_line`
#: is ``None``). It is off only where the line that :meth:`cursor_line` keeps visible is
#: not a highlight that the arrows move: the prompt of the remote CLI, whose ↑↓ recall
#: the history, as in a shell.
edge_scrolls: bool = True
#: Whether Home and End move the page to its top and its bottom after the screen handles
#: them (refer to :meth:`edge_scroll`). In a list, Home and End move the highlight to the
#: first and the last row, and the page follows to its ends. It is off where these keys
#: move a text caret instead: the compose line of the chat, the field of a prompt.
home_end_jumps: bool = True
#: The rows of the terminal at the last paint, or ``None`` before the first paint. The
#: frame records it before it asks for the body (refer to :meth:`note_terminal_rows`).
_terminal_rows: int | None = None
@property
def fkey_lane(self):
"""The F-key lane of the screen on the active platform.
Refer to :mod:`~meshterm.ui.tui.fkeys`. Each handheld has its own lane. This
property reads the lane definition of the screen that the deck of the platform
names (:attr:`picocalc_lyra_lane`, :attr:`cardputer_zero_lane`). A platform with no
lane (the desktop, whose footer is the hint line) gets an empty lane. Override the
definitions for each deck, never this property.
"""
from .fkeys import EMPTY_LANE, active_deck
deck = active_deck()
return EMPTY_LANE if deck is None else deck.read_lane(self)
@property
def cardputer_zero_lane(self):
"""The lane of the screen on the Cardputer Zero: for now, the lane of the PicoCalc.
JP decided on 2026-09-30 that the Cardputer deals the same entries as the PicoCalc,
until a new decision. Its Page Up/Down are real keys, but Fn+L/M is hard to reach.
That the two keyboards both have five slots is a coincidence. Thus this property is
the only place that ties the two lanes together. A screen whose Cardputer lane must
be different overrides this property.
"""
return self.picocalc_lyra_lane
@property
def picocalc_lyra_lane(self):
"""The lane of the screen on the PicoCalc (refer to :mod:`~meshterm.ui.tui.fkeys`).
The lane has five slots, F1–F5. Each slot can have a companion F6–F10 in the Shift
bank. A slot is for a function that is otherwise hard to reach. Enter and Esc never
appear, because these two keys are already easy to reach.
The default puts paging (which has no physical key) on the main F4/F5 pair. It puts
the pair that jumps to the top and the end behind Shift on the same two keys. Home
and End are on this keyboard, and they got a chip only for consistency with the
pager that they belong to. F1–F3 stay free: a screen puts its own verbs there, and a
screen with no verbs leaves them blank. A screen overrides this property for its own
verbs, so the lane can be different on each screen.
The lane is read again at each paint. Thus an override can dim each slot whose
action has no effect at that time, instead of showing a key that does nothing. This
is the permanent rule of the lane (refer to :mod:`~meshterm.ui.tui.fkeys`). A screen
whose navigation keys only scroll controls the shared slots with
:attr:`content_overflows`. This base class cannot assume as much, because on other
screens a navigation key moves a highlight, a selection, or the zoom of the map.
"""
from .fkeys import DEFAULT_LANE
return DEFAULT_LANE
def bar_title(self, title: str, style: str) -> Text:
"""The heading as the borderless title bar draws it: ``title`` in the bar ``style``.
``title`` is the form that the bar chose (:func:`~meshterm.ui.tui.frame.fitted_title`).
A heading that is art instead of words overrides this method to keep the colours of
the art. An example is the wordmark of the main menu, on a frame that has no header
row for it (refer to :attr:`~meshterm.platforms.Platform.header_row`).
"""
return Text(title, style=style)
def __init__(self) -> None:
"""Initialize the scroll state and the result future (assigned later)."""
self.scroll = 0
self.future: asyncio.Future | None = None
# (body-line index, rendered ANSI lines) for each section landmark that can pin to
# the top lines after it scrolls off. A block is usually one line (a section heading,
# a day divider). But it can also hold the lines that belong with it (a heading and
# the description under it). These lines pin together, in order, as they scroll
# off. A screen that wants sticky headers builds this list again while it renders
# its body (refer to :meth:`sticky_block`). The default is no landmarks.
self._sticky_headers: list[tuple[int, list[str]]] = []
# The only header that pins for the full list instead of for its section: the
# column header of a table, which has no meaning after it scrolls off (refer to
# :meth:`sticky_rows`). It is kept in the same form, as (body-line index, rendered
# line). ``None`` if there is none.
self._pinned_header: tuple[int, str] | None = None
# The body height and the viewport of the last render. The frame records them here
# (:meth:`note_metrics`). Thus the shared scroll helpers can page by one viewport of
# the current terminal and clamp to the content, and each caller does not have to
# pass the sizes through.
self._scroll_total = 1
self._scroll_viewport = 1
# The maximum body height and natural width of a grow-only screen: the tallest and
# the widest that it rendered into a dialog. Thus its box can stay at that size after
# it gets there (refer to :meth:`ratchet_viewport` / :meth:`ratchet_width`). Zero
# until the first paint. Not used while :attr:`grow_only` is false.
self._viewport_floor = 0
self._width_floor = 0
# Edge scroll (refer to :meth:`edge_scroll`). These attributes are:
# - whether ↑↓ scrolled the viewport past the highlight, so that the frame no longer
# moves the viewport to keep the highlight visible,
# - the net arrows given to :meth:`handle` since the last paint, which wait for the
# verdict of that paint,
# - the highlight as the last paint drew it, ``(body line, drawn line)``, which the
# verdict compares with,
# - the side of the viewport where the last paint left the highlight (0 visible, -1
# above, +1 below), which decides the snap-back.
# The frame records the last two (:meth:`note_highlight`, :meth:`note_cursor_side`).
self._edge_scrolled = False
self._edge_steps = 0
self._highlight_mark: tuple[int, str] | None = None
self._cursor_side = 0
# --- rendering -----------------------------------------------------------
def render_body(self, width: int) -> list[str]:
"""Return the ANSI lines of the body at ``width`` cells (not clipped).
Args:
width: The inner content width in cells.
Returns:
One ANSI string for each body line. The session slices these lines to the
viewport.
"""
raise NotImplementedError
def ratchet_viewport(self, body_h: int) -> int:
"""The body height that sets the size of a :attr:`grow_only` dialog: its maximum so far.
The frame calls this method at each paint with the height of the current body. A
grow-only screen returns the tallest height that it showed until now. Thus its box
never becomes smaller, and the frame adds blank lines to a body that is now shorter,
to fill the box. A usual screen returns the height without a change, and its size
follows each body when 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 that sets the size of a :attr:`grow_only` dialog: its maximum.
This method is the twin of :meth:`ratchet_viewport`, for the width. The frame
applies it to the ``dialog_width`` that a dialog requests, before the terminal
limit. Thus a grow-only box keeps its widest size when its content becomes narrower,
but a terminal that becomes smaller still clips it. A dialog at the full limit (with
no ``dialog_width``) never gets here.
"""
if not self.grow_only:
return natural
self._width_floor = max(self._width_floor, natural)
return self._width_floor
def cursor_line(self) -> int | None:
"""Return a body line that must stay visible, or ``None`` for free scroll.
A selection screen or a text screen returns the active row, so that the session can
keep it visible. A plain scroll screen returns ``None``. To name the line of the
highlight here is also all that a screen does for edge scroll (refer to
:meth:`edge_scroll`). The frame reads the edges from the page, so no screen tells
where they are.
"""
return None
def consume_edge_scrub(self) -> int:
"""The cells at the right edge that the session must write again at the next paint.
The value is a number of cells (0 = none). The default is 0, because this is not
necessary for a usual screen. A screen whose body can contain glyphs that the
terminal renders at an unexpected width (the braille of the map) overrides this
method. Then the session draws again only the corrupted cells at the edge. Without
this method, the differential paint of prompt_toolkit never writes these cells
again (refer to :class:`~meshterm.ui.map_screen.MapScreen`).
"""
return 0
def sticky_block(self, scroll: int) -> list[str]:
"""The rendered body lines to pin when ``scroll`` moves past a section landmark.
With these lines, a grouped list keeps its current section at the top after the rows
of the section scrolled off. Examples are the ``Channels``/``Direct`` divider of a
select screen, the discipline heading of the Trophy case together with the
description under it, and the ``── Wed Jul 8 ──`` day divider of the chat
transcript. ``scroll`` is the offset at which the body will be sliced. The returned
lines are drawn as the top lines, under the header of the full list if one is pinned
above them (:meth:`sticky_rows` composes them for the frame).
The shared rule: from the blocks that a screen recorded in :attr:`_sticky_headers`
while it rendered, find the last block that starts at or above ``scroll``. This
block *governs* the top visible line. Pin exactly the lines of this block that
``scroll`` passed. Thus a block gives its lines one at a time as they leave. The
heading pins while its description is still the top content line, and the two pin
together after both are gone. The pinned lines always continue smoothly into the
body below. A screen opts in only by filling :attr:`_sticky_headers`. The empty
default means no pins.
A block never takes more than half the viewport. The lines are removed from the end.
Thus, on a short terminal, the heading (the line that tells which section this is)
is the last line to be removed.
"""
governing: tuple[int, list[str]] | None = None
for entry in self._sticky_headers:
if entry[0] <= scroll:
governing = entry
else:
break # blocks are in body order, so no later block can govern
if governing is None:
return [] # no landmark above the top visible line
at, rows = governing
keep = max(1, self._scroll_viewport // 2)
return rows[: min(scroll - at, keep)]
def sticky_rows(self, scroll: int) -> list[str]:
"""All the rendered lines to pin above the body, when the body is sliced at ``scroll``.
The frame draws these lines, from the top down:
- The header of the full list from :attr:`_pinned_header`, after it scrolls off.
This is the column header of a table: the lane names, which have the same meaning
in all the list.
- Then the section block that governs, from :meth:`sticky_block`.
A screen with only section headings pins one line. A grouped table pins both. Thus
the user can still read a scrolled row by its lanes, and also find its group.
Args:
scroll: The offset at which the body will be sliced.
Returns:
The lines to pin, in draw order (empty if there is nothing to pin). Each line
takes one line from the body, so the frame keeps exactly as many lines as it
gets.
"""
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:
"""Respond 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)
# --- edge scroll ---------------------------------------------------------
def edge_scroll(self, action: str) -> bool:
"""Edge scroll and the snap-back: take ``action`` here, before :meth:`handle` gets it.
**Edge scroll** (JP, 2026-10-04) is how each page with a highlight scrolls. Where
the highlight cannot go further, an arrow scrolls the page by one line instead. Thus
the user can always make visible the lines above the first selectable row or below
the last row (the vitals of a node, the lead-in of a list, a closing note). While
the page is edge-scrolled, the frame does not move the viewport to keep the
highlight visible, so the highlight can scroll off the screen. This is intentional.
**Home** and **End** work in the same way: the screen handles them as usual (the
highlight of a list jumps to its first or last row), and the page goes to its top
or its bottom.
**The snap-back**: while the highlight is off the screen, a key that moves it toward
the viewport or acts on it (the arrow that points at it, Enter, Delete) first brings
it back into the viewport, with a jump if necessary, and does nothing more. Nothing
runs that the user cannot see.
**No screen tells where its edges are** (JP, 2026-10-04: one rule, inherited, never
a gate written screen by screen). An arrow goes to :meth:`handle` like all keys, and
the next paint reads the answer from the page (:meth:`note_highlight`). The frame
follows a highlight that moved, as usual. A highlight that did not move was at its
edge, so the page takes the line instead. A screen gives only the line of its
highlight, through :meth:`cursor_line`. The screen already gives that line so that
the frame can keep the highlight visible.
Any other key ends the edge scroll before :meth:`handle` gets it (a typed filter, a
tab change, a page key). Thus the frame follows the highlight again from there. An
arrow that is found at the edge arms the edge scroll again at the paint.
Returns:
Whether the key was taken here. The session gives the key to :meth:`handle`
only when it was not taken.
"""
if not self.edge_scrolls or self._highlight_mark is None:
return False # no highlight on the page: each key belongs to the screen
step = _EDGE_STEPS.get(action)
if (
self._edge_scrolled
and self._cursor_side
and (step == self._cursor_side or action in _SNAP_BACK_ACTIONS)
):
self._edge_scrolled = False # snap-back: this press only brings the highlight back
self._edge_steps = 0
return True
if action in _EDGE_ENDS and self.home_end_jumps:
self.handle(action)
self._edge_scrolled = True
self._edge_steps = 0
if _EDGE_ENDS[action] < 0:
self.scroll_to_top()
else:
self.scroll = self._scroll_total # the frame clamps it to the last full viewport
return True
if step is not None:
self._edge_steps += step # the paint finds whether it moved the highlight
self._edge_scrolled = False
return False
def note_highlight(self, cursor: int | None, lines: Sequence[str]) -> None:
"""Record the highlight as this paint draws it, and judge the arrows that came before.
The frame calls this method at each paint, before it decides the position of the
page. ``cursor`` is :meth:`cursor_line`, and ``lines`` is the body that was just
rendered. Each arrow since the last paint went to :meth:`handle` (refer to
:meth:`edge_scroll`). Each arrow moved the highlight, or found it at its edge. The
page tells which: a highlight on the same body line, drawn in the same way, did not
move. Thus the page scrolls those lines instead, and the frame stops to follow the
highlight. The comparison of the drawn line, not only of its index, tells a
highlight that did not move from a highlight in a list window (refer to
:class:`ListWindow`). There, the highlight stays on one line while the rows go past
under it.
Arrows that are pressed faster than the screen paints are judged together. Thus a
held arrow that gets to the last row in the middle of a batch scrolls from the next
batch on.
"""
mark = None
if self.edge_scrolls and cursor is not None and 0 <= cursor < len(lines):
mark = (cursor, lines[cursor])
if mark is None:
self._edge_scrolled = False
elif self._edge_steps and mark == self._highlight_mark:
self._edge_scrolled = True
self.scroll_lines(self._edge_steps)
self._edge_steps = 0
self._highlight_mark = mark
@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_side(self, side: int) -> None:
"""Record where the last paint left the highlight: 0 visible, -1 above, +1 below.
The frame calls this method. The value decides the snap-back (refer to
:meth:`edge_scroll`).
"""
self._cursor_side = side
def resolve(self, value: Any) -> None:
"""Resolve the future of this screen, and commit ``value`` (or :data:`CANCEL`).
Args:
value: The result to return to the caller that awaits it.
"""
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 body height and the viewport of the last render.
The frame calls this method at each paint. The shared scroll jumps and section jumps
below get their sizes from these values. Thus a PageDown moves by one viewport of
the current terminal, not by a fixed constant. Also, the top and bottom clamps
follow the real content height, and the caller does not have to give the sizes.
"""
self._scroll_total = max(1, total)
self._scroll_viewport = max(1, viewport)
def note_viewport(self, viewport: int) -> None:
"""Record only the viewport height (for callers that do not know the body total)."""
self._scroll_viewport = max(1, viewport)
def note_terminal_rows(self, rows: int) -> None:
"""Record the rows of the terminal, before the body of this paint renders.
The frame calls this method, together with :meth:`note_viewport`, for each type of
frame. :meth:`section_spacing` reads it. The rows must come before the body: rows
recorded after a paint come one paint late, and the first paint of a screen then
comes out without its blank lines and then jumps.
"""
self._terminal_rows = rows
def section_spacing(self) -> bool:
"""Whether a blank line comes before each section of the page, at this paint.
The ``section_spacing_min_rows`` preference decides. A terminal of that many rows
or more gets the line. The line costs one row for each section, so a short
terminal (the Cardputer Zero, or a small window) keeps its rows for content. A
screen that the frame has not painted yet uses the design minimum of the platform
(:attr:`~meshterm.platforms.Platform.readable_rows`). The preference is read at
each paint, so a change on the Preferences page applies when the user comes back.
One rule for each place that has sections: the section headings of a select list
(refer to :attr:`~meshterm.ui.tui.select.Separator.spaced`), and the day dividers
and the authors of a chat.
"""
from ...core.preferences import current as current_preferences
from ...platforms import get_platform
rows = self._terminal_rows
if rows is None:
rows = get_platform().readable_rows
return rows >= current_preferences().section_spacing_min_rows
@property
def _page_step(self) -> int:
"""The lines that PageUp/PageDown move: the viewport, less one line 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: that is, it can scroll.
This is the reusable condition for the rule of the app that a footer shows only a
key that does something. A screen whose body fits has nothing to scroll, so it
removes its ``↑↓ PgUp/PgDn scroll`` atom (refer to
:attr:`DashboardScreen.footer_hint`). It reads the metrics that the frame recorded
at the previous paint (:meth:`note_metrics`). Thus it becomes correct one frame
after a resize, but the frame paints again immediately.
"""
return self._scroll_total > self._scroll_viewport
def scroll_by(self, delta: int, total: int, viewport: int) -> None:
"""Change :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 content bounds that were noted last."""
self.scroll = _clamp_scroll(self.scroll + delta, self._scroll_total, self._scroll_viewport)
def scroll_pages(self, pages: int) -> None:
"""Scroll by ``pages`` page steps (a negative number scrolls up)."""
self.scroll_lines(pages * self._page_step)
def scroll_to_top(self) -> None:
"""Scroll to the first line."""
self.scroll = 0
def scroll_to_bottom(self) -> None:
"""Scroll to the end, so that the last line is at the bottom."""
self.scroll = max(0, self._scroll_total - self._scroll_viewport)
def scroll_to_section_start(self) -> None:
"""Scroll to the top of the section that holds the top visible line.
The stored :attr:`_sticky_headers` mark the limits of the sections (the group
dividers of a select list, the day dividers of the chat transcript). If that heading
is already the top line, scroll to the previous section instead. Thus more presses
go up one section at a time, as the paragraph jump of a text editor does. If there
are no sections, scroll to the top.
"""
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 current section
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:
"""Scroll to the start of the next section below the top visible line (or 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):
"""The ANSI lines of a body, each rendered the first time that something reads it.
A screen renders its full body, and then the frame slices a viewport out of it. Thus a
list of 141 contacts renders 150 rows to show the twenty that fit, at each key press and
at each idle tick. Nothing reads the lines outside the slice. They are built and then
thrown away.
This class is the same list, deferred. A screen gives one entry for each body line. The
entry is the finished string (for the few lines that the screen had to render in any
case: separators, headings, the query echo) or a callable that will render the line.
Only the entries that the frame indexes are called. It is a plain
:class:`~collections.abc.Sequence`. Thus ``len()``, slices, iteration, and ``==``
against a list all behave exactly as the list that it replaces. A slice becomes a real
list of strings, which the frame then pads and puts in its frame.
Two properties make it safe as a replacement:
* **The line count is exact and immediate.** When the drawing of a row is deferred,
the number of rows is never deferred, because the screen counts the rows while it
plans. Thus the scroll clamp, the ``↑↓ more`` markers, and the sticky-header offsets
do not change.
* **A line is rendered a maximum of one time.** The results are cached for each index.
Thus, when the frame reads a row again (a pinned header, a slice that settled again),
it costs nothing more.
The content of a row is resolved in its callable. Thus a :class:`Choice` with a live
callable title is resolved only while it is on the screen: the same rows that a person
can see change.
"""
__slots__ = ("_entries", "_drawn")
def __init__(self, entries: list[str | Callable[[], str]]) -> None:
"""Wrap one entry for each body line: a rendered string, or a callable that renders it."""
self._entries = entries
self._drawn: dict[int, str] = {}
def __len__(self) -> int:
"""The height of the body in lines. It is known without a render of any line."""
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), rendered 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 that this object replaces."""
if isinstance(other, (LazyLines, list)):
return list(self) == list(other)
return NotImplemented
def __repr__(self) -> str:
"""Describe the height, and how many lines are drawn."""
return f"LazyLines({len(self._entries)} lines, {len(self._drawn)} drawn)"
class ListWindow:
"""The scroll state of a list that scrolls inside a fixed screen.
This is the list-window pattern of the app (first used by the link list of the mesh
walk). The screen fits its fixed chrome (headings, charts, action rows) to the
viewport of the frame (refer to :meth:`Screen.note_viewport`: the frame records it
before ``render_body`` runs). Then the screen gives the remaining lines to this class,
and only the rows of the list scroll in them. Faint ``↑ n more`` / ``↓ n more`` markers
(refer to :meth:`marker`) take the edge lines of the list window exactly when rows are
hidden on that side. The content capacity, after it settles, is kept as :attr:`page`:
the step that PgUp/PgDn must move by.
A highlight over such a list **clamps at both ends and does not wrap** (as
:class:`~meshterm.ui.tui.select.SelectScreen` does, for the same reason, which is
stronger here). The list window follows the highlight. Thus, if a ↓ off the last row
rolls over, it does more than move the pointer. It pulls the full list window back to
the start, and changes the edge markers as it goes. To the user, this looks like a change of the
screen under them, not like one step. Held-down arrows stop at an end instead (JP,
2026-09-04). Each screen owns its own highlight, so its ``handle`` keeps this rule.
This class cannot enforce it.
**A list window is for a list that the highlight moves through.** For this reason,
:meth:`fit` and :meth:`fit_blocks` take the highlight with no default. Each row that a
list window hides must be a row that the arrows can reach when the highlight moves onto
it, because the list window follows the highlight. Rows that nothing highlights (a log,
a results table) belong to the page instead. There, the frame scrolls them, and edge
scroll (:meth:`Screen.edge_scroll`) reaches them. When such rows were in a private list
window, they made the page exactly as tall as the screen. Thus the frame found nothing
to scroll: ↓ on the last row did nothing while a ``↓ n more`` was under it (the results
of the trace screen, JP 2026-10-04). ``None`` is still a highlight argument, for a list
whose rows the highlight just left for another part of the same screen.
Attributes:
top: The first list row that the list window shows (:meth:`fit` clamps it at each
paint).
page: The rows that the list window held at the last :meth:`fit`: the paging step.
"""
def __init__(self) -> None:
"""Start at the top, with a safe paging step for the time before the first paint."""
self.top = 0
self.page = 6
def fit(self, n: int, win: int, index: int | None) -> tuple[int, int]:
"""Settle the list window over ``n`` rows in ``win`` lines: ``(top, count)``.
The edge markers take the boundary lines of the list window exactly when rows are
hidden past them. Thus the content capacity changes when the list window slides. A
few passes settle the top, the capacity, and the highlight clamp together.
Args:
n: The total number of list rows.
win: The lines that the list window can use, for content and for markers.
index: The highlighted row, which must stay visible, or ``None`` while the
highlight is in another part of the screen (then ``top`` is only clamped).
Necessary: a list window over rows that no highlight moves through hides
them from the arrows (refer to the class docstring).
Returns:
The first visible row and how many rows to draw. ``top`` rows are hidden above,
and ``n - top - count`` rows below. Each side is non-zero exactly when its
marker line must 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: int | None) -> tuple[int, int]:
"""Settle the list window over rows of different heights: ``(top, count)`` blocks.
This is the sibling of :meth:`fit` for hanging wraps: a list whose rows are blocks
of rendered lines (a route row and the muted facts under it, a wrapped path line),
not one line each. A block is never split across the edge of the list window. The
faint edge markers take a line of the list window exactly when rows are hidden past
them. The list window moves step by step until the highlighted block is visible.
Args:
heights: The rendered line count of each list row.
win: The lines that the list window can use, for content and for markers.
index: The highlighted row, which must stay visible, or ``None`` while the
highlight is in another part of the screen (then ``top`` is only clamped).
Necessary, as for :meth:`fit`.
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 are hidden 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 # move the list window down until the highlighted row is in it
self.top = top
self.page = max(1, count)
return top, count
def to_end(self) -> None:
"""Slide the list window to the end of the list at the next :meth:`fit`.
The value is too large on purpose. The caller seldom knows the list length at the
time of the key press, and :meth:`fit` clamps to the last full list window in all
cases.
"""
self.top = 10**9
@staticmethod
def marker(hidden: int, direction: str) -> Text:
"""The faint edge line that counts the 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 the viewport.
Args:
scroll: The proposed offset.
total: The total line count.
viewport: The height of the viewport 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 viewport that scrolls.
It is used for the result screens of tools and for all long lists. Content that is
taller than the viewport scrolls with the arrows, PageUp / PageDown (one viewport), and
Home / End (or Ctrl+Home / Ctrl+End). Esc closes it. Result screens have no sections, so
Ctrl+PageUp/PageDown only go to the top or the bottom.
"""
@property
def picocalc_lyra_lane(self):
"""The shared lane, with its navigation slots dimmed when the content already fits.
Each navigation key here only scrolls. Thus a result screen that is short enough to
read in full has four slots that do nothing. The footer hint on the other platform
uses the same condition (: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: str | None = None,
floating: bool = True,
) -> None:
"""Wrap a renderable in a screen that the user can scroll and close.
Args:
renderable: The Rich content to show.
title: The heading of the screen or the dialog.
footer_hint: The footer key hint, or ``None`` to make it from the state of the
screen (refer to :attr:`footer_hint`).
floating: Whether to draw the screen as a dialog at the centre, over the parent
screen.
"""
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: the hint that the caller gave, or the correct default.
A read-only body has only two keys to name: the pager and Esc. Thus the hint can be
made from the state of the screen, instead of written at each call site. A hint
made in this way keeps the permanent rule that a footer never shows a key that does
nothing. The scroll atom appears only while the body is taller than its viewport
(:attr:`Screen.content_overflows`). The Esc verb follows the surface, as the
standards say: a floating read-only dialog closes, and 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 replace_content(self, renderable: RenderableType) -> None:
"""Replace the body in place, and keep the user at the same position.
This is the :meth:`~meshterm.ui.tui.select.SelectScreen.replace_items` of the
select list, for a read-only body. When something on a page changes (a value that
is now shown, a figure that is refreshed), it is still the page that the user was
part of the way down. Thus the scroll offset stays. (The next scroll clamps the
offset in any case. Thus a body whose height changed cannot leave the viewport past
its end.) Build the screen again instead when its content is really a different
subject.
"""
self._renderable = renderable
def render_body(self, width: int) -> list[str]:
"""Render the wrapped content to ANSI lines, and keep 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, by page, or to an edge), or close 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 next to a message.
It keeps the startup splash on the screen, with no change to its wordmark and its box,
while a short async task runs (for example, a smoke test of a companion that the user
chose). It is set as a base without chrome. Thus it draws again the same splash at the
centre under the wordmark, and changes only the contents of the box. It resolves nothing
and ignores all keys. 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 # the work owns the keyboard. ^W must not unwind the stack while it runs.
@property
def picocalc_lyra_lane(self):
"""No lane: this screen ignores all keys, so no key has an effect."""
from .fkeys import EMPTY_LANE
return EMPTY_LANE
def __init__(self, message: str, *, title: str = "") -> None:
"""Start a busy splash that shows ``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 animation step.
The animation timer of the session calls this method.
"""
self._spinner.tick()
def render_body(self, width: int) -> list[str]:
"""Render the current animation step of the spinner, then the message.
A caption can contain line feeds: what the task does on one line, and its progress
on the next line. Each line after the first hangs under the text of the caption,
not under the spinner. The same is true for each line that is too long for the box.
This is the rule for wrapped rows that each labelled block follows. When the caption
wrapped without this rule, a caption that ended in a count moved the last letter of
the count alone onto a line, at the left edge.
"""
first, *more = self._message.split("\n")
prefix = self._spinner.text()
prefix.append(" ")
indent = prefix.cell_len
lines = render_hanging(prefix, Text(first), width, indent=indent)
for line in more:
lines += render_hanging(Text(" " * indent), Text(line), width, indent=indent)
return lines
def handle(self, action: str, data: str = "") -> None:
"""Ignore all keys: the splash closes itself when the task finishes."""
return
class BusyDialog(BusyScreen):
"""A busy splash, drawn as a box over the screen that it interrupts.
It is the twin of :class:`BusyScreen`, for work that starts from a screen, not instead
of a screen. It has the same spinner, the same caption, and the same
:meth:`~BusyScreen.handle` that ignores all keys. But it is ``floating``. Thus the hub
that started the work stays visible below it as a backdrop, and is not replaced.
It is modal because it inherits from :class:`BusyScreen`, and this is the important
part. A hub screen that ``session.stay`` keeps on the stack is armed all the time (refer
to :meth:`~meshterm.ui.tui.session.Visit._arm`). Thus, while the hub waits for a slow
write to the device, each key that the user presses still gets to it. Letters go into
its live filter, and Esc resolves it, and that result is returned at the moment that
the work finishes. A modal screen on top stops this: the key gets to this dialog and
stops here.
:meth:`~meshterm.ui.tui.session.TuiSession.busy_overlay` cannot do this job. It is not
a screen, so it never gets a key press. It paints only when the stack is empty, so over
a pushed hub it draws nothing.
"""
floating = True
@property
def dialog_width(self) -> int:
"""Fit the box to the caption, instead of to the maximum width of a dialog.
A spinner and four words must not be in a box of 94 cells. The compositor still
limits this width to the terminal (refer to
:func:`~meshterm.ui.tui.frame._dialog_layout`). The width also ratchets (it only
grows). Thus a caption that becomes longer during a batch never makes the box
smaller again, and the box does not jitter.
"""
widest = max(cell_len(line) for line in self._message.split("\n"))
return widest + 8 # spinner, its two spaces, and the padded border
@property
def message(self) -> str:
"""The caption next to the spinner. A line feed starts a line that hangs under its text."""
return self._message
@message.setter
def message(self, text: str) -> None:
"""Change the caption during a batch (a sequence of writes that tells which one runs)."""
self._message = text