Files
MeshTerm/meshterm/ui/tui/session.py
T
Jean-Pierre MartineauandClaude Opus 5 7ca0069247 Say why the quit chords skip the confirmation
^Q and ^C exit straight out, and the next reader of that dispatch block would
reasonably wonder whether the missing confirm was an oversight. It isn't: the
quit dialog is there to catch one Esc too many at the main menu, where a single
reflexive keystroke would otherwise end the session (JP, 2026-08-30). A hidden
two-key chord cannot be pressed by accident, so there is nothing for a confirm
to catch — and a confirm is itself a screen that has to paint and take a key,
which is precisely what an escape hatch cannot depend on.

Comment only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:29:50 -04:00

1750 lines
81 KiB
Python
Raw 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.
"""The persistent full-screen session: the heart of the TUI library.
A :class:`TuiSession` owns one prompt_toolkit :class:`Application`, a stack of
:class:`~meshterm.ui.tui.screen.Screen` layers, and the persistent header/footer frame. It
translates prompt_toolkit key events into normalized *actions* dispatched to the top screen,
composes the current view (bounded to the terminal, reflowing on resize) via
:mod:`~meshterm.ui.tui.frame`, and exposes ``async`` helpers (``select``/``text``/
``confirm``/``autocomplete``/``scroll``/``progress``) that push a screen, await its result,
and pop it — the push/await/pop model behind every prompt.
"""
from __future__ import annotations
import asyncio
import os
import sys
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Callable, Optional, Sequence
from prompt_toolkit.application import Application
from prompt_toolkit.data_structures import Size
from prompt_toolkit.filters import Condition
from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout import ConditionalContainer, Float, FloatContainer, Layout, Window
from prompt_toolkit.utils import get_cwidth
from rich.console import RenderableType
from rich.text import Text
from ...platforms import get_platform
from ...services import modifier_watch
from . import fastrender, fkeys, frame
from .emoji_width import ClusterTextControl
from .overlay import BusyOverlay
from .progress import TuiProgress
from .prompt import (
AutocompleteScreen,
ButtonDialog,
ConfirmScreen,
PinDialog,
TextScreen,
TypedConfirmDialog,
Validator,
)
from .screen import CANCEL, POP_ALL, BusyScreen, PopToMenu, Screen, ScrollScreen
from .spinner import spinner_interval
from .select import Choice, ReorderScreen, SelectScreen, Separator
#: Every Ctrl-letter chord the app binds, keyed by the bare lowercase letter — the single
#: source of truth for both halves of a chord's life: the ``Keys.Control*`` bindings folded
#: into :data:`_KEY_ACTIONS` below, and the right-Ctrl rescue in :meth:`TuiSession._dispatch`
#: (see :func:`_right_ctrl_down`), which promotes the bare letter a layout-claimed right Ctrl
#: delivers as *text* back into the chord. Add a chord here and both Ctrl keys reach it — no
#: second edit, no chord that works on one side of the keyboard only. ``quit`` and
#: ``paste_clipboard`` are the session's own (answered in ``_dispatch``); the rest are actions
#: forwarded to the top screen. A bare letter only reaches text while the right Ctrl is held
#: when the layout has no third-level glyph for that key (i.e. it really is the chord) — a
#: genuine third-level character arrives as some *other* glyph and never matches this table.
#: ``m``/``i``/``h`` are not free: prompt_toolkit spells Enter, Tab and Backspace as
#: ``Keys.ControlM``/``ControlI``/``ControlH``, so claiming them here would rebind those keys.
#: A chord's letter is the mnemonic of the *action*, not of one screen's word for it —
#: ``locate`` is ^U for **you** (JP, 2026-08-09), the same key on the map and in the mesh
#: walk, because what it names on both is our own node.
#:
#: Two of these are app-wide rather than a screen's: ``to_menu`` (^W) unwinds the whole
#: navigation stack back to the main menu, and ``quit`` (^Q, alongside the older ^C) leaves
#: the app from anywhere. Neither is advertised in a footer hint or an F-key chip — the lane
#: has three free slots per screen and a global verb would claim one on every screen forever,
#: so both are stated once on the About page's key list instead (JP, 2026-08-30). ^W was
#: picked for the close-this-whole-thing reflex; both were verified deliverable on the
#: Canadian Multilingual layout (^W ``U+0017``, ^Q ``U+0011``) and neither is eaten by the
#: terminal — prompt_toolkit's raw mode clears ``IXON``/``IXOFF``, so ^Q is not XON here.
_CTRL_LETTER_CHORDS: dict[str, str] = {
"c": "quit",
"p": "paths",
"q": "quit",
"r": "retry",
"u": "locate",
"v": "paste_clipboard",
"w": "to_menu",
}
#: Maps prompt_toolkit keys to the normalized action names screens understand.
_KEY_ACTIONS: dict[Any, str] = {
Keys.Up: "up",
Keys.Down: "down",
Keys.Left: "left",
Keys.Right: "right",
Keys.ShiftUp: "shift_up",
Keys.ShiftDown: "shift_down",
Keys.ShiftLeft: "shift_left",
Keys.ShiftRight: "shift_right",
Keys.PageUp: "pageup",
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.ControlUp: "ctrl_up",
Keys.ControlDown: "ctrl_down",
Keys.Enter: "enter",
# Ctrl+Enter, as far as a terminal can spell it. There is no such keycode: Enter *is*
# ^M, so a console that doesn't do modifyOtherKeys has nothing left to modify and
# sends plain CR. What most do send is LF — prompt_toolkit's ``ControlJ`` — which is
# otherwise unbound here (an unprintable char, dropped by ``_typed``), so claiming it
# costs nothing and wins on the terminals that offer it. The right-Ctrl rescue below
# covers the rest through _CTRL_CHORDS, and on a console that spells neither, the
# F-lane chip is the affordance (which is the platform where that is already true).
Keys.ControlJ: "ctrl_enter",
Keys.Escape: "escape",
Keys.Backspace: "backspace",
Keys.Delete: "delete",
Keys.Tab: "tab",
Keys.BackTab: "shift_tab",
# The function keys, for the PicoCalc's F-key lane (F6–F10 are its Shift bank —
# the MCU translates Shift+F1..F5 into these plain keycodes). The session resolves
# them against the top screen's lane in _dispatch; on platforms without the lane
# they resolve to nothing and fall away. Alt+Fn is the kernel's VT switch on the
# device and must never be bound.
**{getattr(Keys, f"F{n}"): f"f{n}" for n in range(1, 11)},
# The Ctrl-letter chords, generated from the one table above so a chord can never be
# bound without its right-Ctrl rescue (or rescued into an action nothing binds).
**{
getattr(Keys, f"Control{letter.upper()}"): action
for letter, action in _CTRL_LETTER_CHORDS.items()
},
}
#: The plain actions that have a Ctrl-chord sibling, for the right-Ctrl rescue in
#: :meth:`TuiSession._dispatch` (see :func:`_right_ctrl_down`). Navigation keys, plus
#: ``enter`` — the one key a terminal cannot reliably spell chorded (see ``Keys.ControlJ``
#: above), so the rescue is not a fallback there but the surer of the two paths.
_CTRL_CHORDS: dict[str, str] = {
"up": "ctrl_up",
"down": "ctrl_down",
"left": "ctrl_left",
"right": "ctrl_right",
"home": "ctrl_home",
"end": "ctrl_end",
"pageup": "ctrl_pageup",
"pagedown": "ctrl_pagedown",
"enter": "ctrl_enter",
}
def _right_ctrl_down() -> bool:
"""Whether the right Ctrl key is physically held right now (Windows; ``False`` elsewhere).
The rescue behind right-Ctrl chords. Keyboard layouts that claim the right Ctrl key as a
character-group modifier — the Canadian Multilingual Standard uses it to reach a third
character level — can deliver a right-Ctrl'd arrow to the console as a *bare* arrow, no
ctrl flag left for prompt_toolkit to map, so only the left Ctrl ever steered a sortable
list. This probes the physical key state (``GetAsyncKeyState``) instead of trusting the
stripped event modifiers: if an arrow reached our dispatch, our terminal had focus, so a
right Ctrl held at that instant is the user chording it. Non-Windows platforms report
``False`` — a VT terminal encodes the ctrl modifier side-agnostically itself, and there
is no per-side key state to consult anyway.
"""
if sys.platform != "win32":
return False
try:
import ctypes
return bool(ctypes.windll.user32.GetAsyncKeyState(0xA3) & 0x8000) # VK_RCONTROL
except Exception: # noqa: BLE001 - a failed probe just leaves the plain action
return False
def _read_clipboard() -> str:
"""Best-effort read of the OS clipboard's Unicode text (Windows; ``""`` elsewhere).
The fallback behind Ctrl-V on a terminal that delivers the key literally rather than as a
bracketed paste (see :meth:`TuiSession._dispatch`): it pulls the clipboard's text
straight from the Win32 API. Every failure — a non-Windows platform, an empty or
non-text clipboard, a clipboard busy elsewhere we couldn't open — collapses to ``""``, so
the paste simply does nothing rather than raising into the key handler. Pointer-returning
calls declare a ``c_void_p`` result so a 64-bit handle isn't truncated to an int.
"""
if sys.platform != "win32":
return ""
try:
import ctypes
CF_UNICODETEXT = 13
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
user32.GetClipboardData.restype = ctypes.c_void_p
user32.GetClipboardData.argtypes = [ctypes.c_uint]
kernel32.GlobalLock.restype = ctypes.c_void_p
kernel32.GlobalLock.argtypes = [ctypes.c_void_p]
kernel32.GlobalUnlock.argtypes = [ctypes.c_void_p]
if not user32.OpenClipboard(None):
return ""
try:
handle = user32.GetClipboardData(CF_UNICODETEXT)
if not handle:
return ""
ptr = kernel32.GlobalLock(handle)
if not ptr:
return ""
try:
return ctypes.wstring_at(ptr)
finally:
kernel32.GlobalUnlock(handle)
finally:
user32.CloseClipboard()
except Exception: # noqa: BLE001 - a clipboard hiccup must never break a keypress
return ""
def _reclaim_last_column() -> bool:
"""Whether to reclaim the terminal's final column.
Some terminals (and prompt_toolkit's size probe on them) report the window one column
narrower than it really is, so the frame is drawn to ``columns - 1`` and the true last
column sits unused — visibly selectable to the right of the border. When this is on,
:class:`_WidthExtendedOutput` tells both the renderer and the frame compositor the
window is one column wider, and that final column gets drawn.
This is a gate, not a certainty: it is *correct* only when the probe under-reports. On a
terminal whose width probe is already right, the extra column falls off the real screen and
the frame would wrap and tear — which is exactly PicoCalc's exact-width console, so
:data:`~meshterm.platforms.PICOCALC` defaults this off. ``MESHTERM_FULL_WIDTH=0``/``=1``
remains an explicit override on top of the platform default for any terminal that needs
to disagree with its platform's usual verdict (TODO: thread through ``settings`` and the
config editor once confirmed further).
Read fresh on every call (never cached at import time) so it reflects whichever platform
:func:`~meshterm.platforms.set_platform` installed for this process — see that module's
docstring for why a cached/imported copy of the platform would go stale.
"""
override = os.environ.get("MESHTERM_FULL_WIDTH")
if override is not None:
return override != "0"
return get_platform().width_reclaim
#: How many stacked dialog layers the layout can float over the background at once. A fixed
#: pool of centered-box floats (see :meth:`TuiSession._build_app`), sized well past the deepest
#: real nesting — a tool's list, an item's detail popup, and a confirm over that is only three.
_MAX_DIALOG_LAYERS = 8
def _changed_rows(before: str, after: str) -> Optional[list[int]]:
"""Which lines of a full-screen frame differ, or ``None`` when they can't be compared.
Both frames are composed one line per terminal row (see
:func:`~meshterm.ui.tui.frame.compose_base`), so a line index *is* a row index — the
mapping :meth:`TuiSession._scrub_rows` needs. A differing line count means the frame's
height moved (a resize, a splash giving way to the framed layout) and no such mapping
holds; the caller falls back to repainting everything.
"""
old, new = before.split("\n"), after.split("\n")
if len(old) != len(new):
return None
return [i for i, (a, b) in enumerate(zip(old, new)) if a != b]
def _has_wide_glyph(text: str) -> bool:
"""Whether ``text`` holds a glyph prompt_toolkit reserves two cells for.
A width-2 glyph is where the terminal and the renderer can disagree: an emoji (``👋``)
that the terminal draws in a *single* cell is the common case here — pt reserves two,
the terminal advances one, and from that point the row's cursor model is off. Node-type
marks (``▲●■``) and chart braille are width-1 everywhere, so they never trip this. The
``>= 0x1100`` guard skips the ASCII/Latin bulk of a frame before the width lookup, which
matters because this runs over the whole composed frame on every repaint.
On a platform that draws no emoji the answer is ``False`` by construction — its console
font is a fixed 512-glyph set with no wide glyph in it, so nothing a frame can contain
would return ``True``. Answering from the platform instead of the text skips a
per-character scan of the entire frame on every repaint, and with it the scrub machinery
that a ``True`` would arm, leaving the cheap differential paint permanently in play.
"""
if not get_platform().emoji:
return False
return any(ord(ch) >= 0x1100 and get_cwidth(ch) == 2 for ch in text)
class _WidthExtendedOutput:
"""A prompt_toolkit ``Output`` proxy that reports one extra terminal column.
Wraps the real output and forwards everything untouched *except* :meth:`get_size`, which
adds a column. Because the whole render pipeline — prompt_toolkit's differential renderer
and MeshTerm's own frame compositor (via :meth:`TuiSession._size`) — keys off
``output.get_size()``, this single override makes both use the reclaimed column in
lock-step. See :func:`_reclaim_last_column` for when this is right (and when it isn't).
"""
def __init__(self, inner: Any) -> None:
"""Wrap ``inner`` (the concrete prompt_toolkit output for the real terminal)."""
self._inner = inner
def get_size(self) -> Size:
"""The wrapped size with one column added, so the last column is claimed."""
size = self._inner.get_size()
return Size(rows=size.rows, columns=size.columns + 1)
def __getattr__(self, name: str) -> Any:
"""Forward every other attribute/method straight to the wrapped output."""
return getattr(self._inner, name)
def _message_border(message: "Text | str") -> str:
"""Pick a message dialog's border style from the strongest tone in the text.
Outcome notes carry their severity as theme spans (``[err]``, ``[warn]``, ``[ok]``),
so the dialog frame can echo it: an error message gets the ``err`` border, a warning
(e.g. "device rebooting") the cautionary one, and anything else — successes included —
the standard accent. A plain string carries no spans and always reads as neutral.
Args:
message: The dialog's message, styled or plain.
Returns:
The Rich style name for the dialog border.
"""
if isinstance(message, Text):
styles = {str(span.style) for span in message.spans}
if "err" in styles:
return "err"
if "warn" in styles:
return "warn"
return "accent"
class Visit:
"""One screen's stay on the stack, handed out by :meth:`TuiSession.stay`.
Holds nothing but the session and the screen; :meth:`result` arms a fresh future on that
screen and awaits it, leaving the screen exactly where it is. Calling it again is the
next round of the same visit — the loop shape a hub screen wants.
"""
__slots__ = ("_session", "_screen")
def __init__(self, session: "TuiSession", screen: Screen) -> None:
"""Bind a visit to its session and screen, and arm the screen's first round."""
self._session = session
self._screen = screen
self._arm()
@property
def screen(self) -> Screen:
"""The screen being visited (the same object for the whole stay)."""
return self._screen
def _arm(self) -> None:
"""Give the screen a fresh future to resolve into.
A visited screen is *always* armed — from the moment it is pushed until the visit
ends — because it is on the stack the whole time and a key can reach it whenever it
is on top. Arming only inside :meth:`result` would leave a gap between rounds in
which :meth:`~meshterm.ui.tui.screen.Screen.resolve` has nowhere to put its value
and the press is silently dropped.
"""
self._screen.future = asyncio.get_running_loop().create_future()
async def result(self) -> Any:
"""Await one round of the visited screen: its resolved value, or ``CANCEL`` on Esc.
A round already resolved — because the screen was armed while the caller was still
busy with the last one — is returned immediately rather than waited for again.
Raises:
PopToMenu: If the pop-all key was pressed, here or while the caller was busy.
"""
self._session._check_unwind()
future = self._screen.future
if future is None:
self._arm()
future = self._screen.future
try:
return self._session._unpack(await future)
finally:
self._arm() # the next round is live the moment this one is read
class TuiSession:
"""A running full-screen TUI: screen stack, frame, input loop, and async prompts."""
def __init__(
self,
header: Optional[Callable[[int], RenderableType]] = None,
*,
input: Any = None, # noqa: A002 - matches prompt_toolkit's Application(input=) name
output: Any = None,
) -> None:
"""Create a session.
Args:
header: Callable taking the terminal width in columns and returning the
persistent header renderable (banner + live status), re-invoked on
every repaint — the width lets it stretch trailing content (the
activity sparkline) to fill the row exactly. Defaults to a plain title.
input: Optional prompt_toolkit input to drive the app from (tests use a pipe);
defaults to the real terminal.
output: Optional prompt_toolkit output to render to (tests use a dummy);
defaults to the real terminal.
"""
self._stack: list[Screen] = []
self._header = header or (lambda cols: Text("MeshTerm", style="brand"))
self._app: Optional[Application] = None
self._input = input
self._output = output
# The top-most floating "working" overlay (a skeleton card), or None when idle. It is
# deliberately *not* on the screen stack: it hovers above every layer and is shown/
# hidden by busy_overlay, independent of whatever screens are pushed.
self._overlay: Optional[BusyOverlay] = None
# What each drawn layer last composed — ``layer -> (text, carries a wide glyph)`` — so
# :meth:`_emit` can tell a frame that actually changed from one the 1 Hz refresh just
# re-rendered identically, and can ask whether anything currently on screen needs the
# full-repaint treatment. Reconciled against the layers actually drawn on every paint
# (see :meth:`_reconcile_layers`).
self._layers: dict[str, tuple[str, bool]] = {}
# Armed by the pop-all key (^W) and disarmed by the menu loop once it has caught the
# unwind. While armed, every navigation boundary raises PopToMenu rather than showing
# another screen — see :meth:`request_pop_all`.
self._unwinding = False
# The screen the unwind lands on (the main menu), so ^W can no-op when it is already
# the top rather than pointlessly rebuilding it. ``None`` until the menu declares it.
self._root: Optional[Screen] = None
# --- stack ---------------------------------------------------------------
@property
def top(self) -> Optional[Screen]:
"""The active (top-most) screen, or ``None`` when the stack is empty."""
return self._stack[-1] if self._stack else None
def push(self, screen: Screen) -> None:
"""Push a screen onto the stack and repaint."""
self._stack.append(screen)
self.invalidate()
def pop(self, screen: Optional[Screen] = None) -> None:
"""Pop ``screen`` (or the top) off the stack and repaint."""
if not self._stack:
return
popped: Optional[Screen] = None
if screen is None or self._stack[-1] is screen:
popped = self._stack.pop()
elif screen in self._stack:
self._stack.remove(screen)
popped = screen
self._expose_overlay()
# A floating dialog is drawn as a content-sized box over the screen beneath. If any
# of its cells held a glyph the terminal painted wider than prompt_toolkit tracks
# (an emoji or box-draw fallback the differential renderer can't see), that overhang
# would otherwise survive as stray characters in the dialog's wake. Force the frame
# beneath to rewrite every cell as the dialog is torn down so nothing of it lingers.
if popped is not None and getattr(popped, "floating", False):
self._invalidate_last_frame()
self.invalidate()
def reset(self) -> None:
"""Clear the whole screen stack and repaint.
Used to unwind to a blank frame after a cancelled activity — e.g. when a mid-session
device disconnect abandons whatever screens the interrupted work had pushed, before
the reconnect dialog is shown over a clean slate. Screens hold no external resources
(their callers pop them in ``finally``), so dropping any stragglers here is safe.
"""
self._stack.clear()
# A reset is its own unwind — the caller has already abandoned whatever was running
# (see :func:`~meshterm.ui.menu._session_loop`), so an armed ^W has nothing left to
# unwind and would otherwise fire into the reconnect flow that replaces it.
self._unwinding = False
self._expose_overlay()
self.invalidate()
def _expose_overlay(self) -> None:
"""Restart the busy overlay's fade whenever a stack change re-exposes it.
A prompt pushed over an active overlay hides the card; popping back to an empty stack
re-exposes it. Restart the intro then so the black hold and fade-in replay fresh each
time the card is shown, rather than snapping back at full brightness (see
:meth:`~meshterm.ui.tui.overlay.BusyOverlay.restart`).
"""
if self._overlay is not None and not self._stack:
self._overlay.restart()
# --- navigation ----------------------------------------------------------
def set_root(self, screen: Optional[Screen]) -> None:
"""Declare ``screen`` the navigation root — where an unwind lands.
The main menu calls this with its own list. Only two things depend on it: ^W is a
no-op while the root is already the top screen (you cannot go back to where you
are), and nothing else in the app needs to know which screen is the menu.
Args:
screen: The root screen, or ``None`` to forget it (the menu on its way out).
"""
self._root = screen
def request_pop_all(self) -> bool:
"""Arm the unwind to the navigation root (the ^W key). Returns whether it fired.
Two refusals, both deliberate:
* **A modal layer is on top.** A dialog is an unanswered question and a progress or
busy screen is work in flight (:attr:`~meshterm.ui.tui.screen.Screen.modal`);
unwinding past either would discard something the user is in the middle of, so ^W
simply does nothing there and they answer or wait first.
* **The root is already the top.** There is nowhere to go.
Otherwise it sets the unwind flag and resolves the top screen's future with
:data:`~meshterm.ui.tui.screen.POP_ALL`, which the frame awaiting it turns into
:class:`~meshterm.ui.tui.screen.PopToMenu`. The flag matters independently of the
future: a key can arrive while *no* screen is awaiting anything (mid device read,
under the busy overlay), and then the unwind is raised by the next navigation
boundary instead — see :meth:`_check_unwind`.
Returns:
``True`` if the unwind was armed, ``False`` if it was refused.
"""
top = self.top
if top is not None and (top.modal or top is self._root):
return False
self._unwinding = True
if top is not None:
top.resolve(POP_ALL)
return True
def unwound(self) -> None:
"""Disarm the unwind — called by the menu loop once it has caught :class:`PopToMenu`."""
self._unwinding = False
def _check_unwind(self) -> None:
"""Raise :class:`PopToMenu` if an unwind is armed, before showing another screen.
Every navigation boundary calls this on the way *in* as well as reading the result on
the way out, which is what makes ^W work during a device read: the key arms the flag
while no future is armed to carry :data:`POP_ALL`, and the very next screen the flow
tries to open refuses to open and unwinds instead.
"""
if self._unwinding:
raise PopToMenu()
@staticmethod
def _unpack(result: Any) -> Any:
"""Return a screen's result, turning :data:`POP_ALL` into :class:`PopToMenu`.
The sentinel exists only to travel through an ``asyncio.Future``; no caller in the
app ever sees it, so it is converted at the one boundary that reads a future.
"""
if result is POP_ALL:
raise PopToMenu()
return result
@asynccontextmanager
async def stay(self, screen: Screen) -> AsyncIterator["Visit"]:
"""Keep ``screen`` pushed for a whole visit while its sub-screens come and go.
The counterpart to :meth:`run_screen`, and the shape every screen that *owns a loop*
should use::
screen = ContactsScreen(...)
async with session.stay(screen) as visit:
while True:
chosen = await visit.result()
if chosen is CANCEL:
return
await open_node_detail(ctx, chosen) # nests above the list
One push, one pop, and the object survives the whole visit — so the cursor, the sort,
the scroll offset and any live filter are simply still there when a sub-screen closes,
without a ``default=`` restore that can only ever recover the cursor. Because the
screen never leaves the stack, a dialog raised from inside the loop already has it as
a backdrop, and a full-frame sub-screen pushed above it draws over it (see
:meth:`_base_index`) — the two things callers used to arrange by popping and
re-pushing the same screen by hand.
Args:
screen: The screen to keep pushed for the duration of the block.
Yields:
A :class:`Visit` whose :meth:`~Visit.result` awaits one round of the screen.
"""
self._check_unwind()
self.push(screen)
try:
yield Visit(self, screen)
finally:
self.pop(screen)
def invalidate(self) -> None:
"""Request a repaint if the application is running."""
if self._app is not None:
self._app.invalidate()
def request_full_repaint(self) -> None:
"""Force the next paint to rewrite every cell, then schedule it.
prompt_toolkit repaints differentially: cells equal to the previous frame are left
untouched. That is normally what we want, but it also means terminal-side corruption
(a double-width fallback glyph the diff can't see) survives until those exact cells
change. Callers use this when leaving a screen that could have smeared the terminal —
the map — so the screen drawn underneath starts from a clean slate.
"""
self._invalidate_last_frame()
self.invalidate()
def _invalidate_last_frame(self) -> None:
"""Drop prompt_toolkit's cached last frame so the next paint rewrites every cell.
Touches a prompt_toolkit internal, so it fails soft if the attribute ever moves.
"""
renderer = getattr(self._app, "renderer", None)
if renderer is not None and hasattr(renderer, "_last_screen"):
renderer._last_screen = None
def _scrub_columns(self, start: int, stop: int) -> None:
"""Force prompt_toolkit to repaint terminal columns ``[start, stop)`` on the next diff.
Overwrites those cells in pt's remembered last frame with a sentinel that can't equal
any real content, so the differential renderer treats them as changed and redraws
them — scrubbing a double-width fallback glyph that smeared over a *static* edge (the
map's panel border, a floating dialog's), without the whole-frame flicker of dropping
the entire cached frame. Touches a pt internal, so it fails soft if the structure ever
moves.
"""
renderer = getattr(self._app, "renderer", None)
last = getattr(renderer, "_last_screen", None)
if last is None:
return
try:
from prompt_toolkit.layout.screen import Char
buffer = last.data_buffer
sentinel = Char("￿") # a non-character; never equals real cell content
for x in range(max(0, start), stop):
for row in list(buffer.keys()):
buffer[row][x] = sentinel
except Exception: # noqa: BLE001 - a cosmetic scrub must never break rendering
pass
def _scrub_right_columns(self, count: int) -> None:
"""Scrub the terminal's rightmost ``count`` columns — a full-frame panel's edge."""
cols, _ = self._size()
self._scrub_columns(cols - count, cols)
def _scrub_rows(self, rows: Sequence[int]) -> bool:
"""Force prompt_toolkit to rewrite these whole terminal rows on the next diff.
The row-wise twin of :meth:`_scrub_columns`, and the cheap form of the wide-glyph
repaint (:meth:`_emit`): every column of each listed row is sentinelled, so pt finds
the entire row changed and writes it from column 0 in one contiguous run — which is
the whole requirement for a row whose glyph the terminal draws narrower than pt
reserved. Rows that did not change are not touched at all, and nothing is erased.
Row-scoped is sound because pt's cursor is *relative*: stepping down a row emits
``\\r\\n``, which returns the terminal to a true column 0 whatever the drift on the row
above, so a mis-measured row can never throw off the rows below it. The drift only
matters *within* a row, and a row rewritten whole never jumps inside itself.
Args:
rows: The terminal row indices to mark changed.
Returns:
Whether the scrub was applied — ``False`` when pt has no remembered frame to
scrub (it is already going to repaint everything) or the internals moved.
"""
renderer = getattr(self._app, "renderer", None)
last = getattr(renderer, "_last_screen", None)
if last is None:
return False
cols, _ = self._size()
try:
from prompt_toolkit.layout.screen import Char
buffer = last.data_buffer
sentinel = Char("￿") # a non-character; never equals real cell content
for y in rows:
row = buffer[y]
for x in range(cols):
row[x] = sentinel
except Exception: # noqa: BLE001 - a cosmetic scrub must never break rendering
return False
return True
# --- async prompt helpers ------------------------------------------------
async def run_screen(self, screen: Screen) -> Any:
"""Push a screen, await its result, then pop it.
Args:
screen: The screen to run.
Returns:
The screen's resolved value, or :data:`~meshterm.ui.tui.screen.CANCEL`.
Raises:
PopToMenu: If the pop-all key was pressed — on this screen, or while the caller
was busy and had nothing pushed at all.
"""
self._check_unwind()
loop = asyncio.get_running_loop()
screen.future = loop.create_future()
self.push(screen)
try:
result = await screen.future
finally:
self.pop(screen)
return self._unpack(result)
async def select(
self,
title: str,
items: list,
*,
prompt: str = "",
default: Any = None,
wrap: bool = True,
filterable: bool = True,
footer_hint: Optional[str] = None,
delete_hint: str = "",
) -> Any:
"""Show a select screen; return the chosen value or ``None`` if cancelled.
``prompt`` draws an instruction inside the box above the list; ``filterable`` and
``footer_hint`` are forwarded for short, fixed lists (a yes-or-no style choice) that
want no type-to-filter and a tailored hint. ``delete_hint`` (with rows marked
:attr:`~meshterm.ui.tui.select.Choice.deletable`) surfaces the Delete key's atom
while the highlight sits on such a row; Delete then resolves a
:class:`~meshterm.ui.tui.select.DeleteRequest` the caller unwraps.
"""
kwargs: dict[str, Any] = dict(
prompt=prompt, default=default, wrap=wrap, filterable=filterable,
delete_hint=delete_hint,
)
if footer_hint is not None:
kwargs["footer_hint"] = footer_hint
result = await self.run_screen(SelectScreen(title, items, **kwargs))
return None if result is CANCEL else result
async def select_startup(
self,
title: str,
items: list,
*,
default: Any = None,
banner: Optional[Any] = None,
footnote: Optional[str] = None,
footer_hint: str = "↑↓ move · Enter select · Esc quit",
) -> Any:
"""Show a chromeless select splash (banner above a content-sized box).
Like :meth:`select`, but drawn without the header/footer status bars and centered
under ``banner`` — the startup device picker's presentation. Type-to-filter is off:
the device list is short and fixed, so stray keys never narrow it. An optional
``footnote`` (e.g. a copyright notice) sits muted below the box. When any row opts
into removal (a :attr:`~meshterm.ui.tui.select.Choice.deletable` row), a "Del remove"
atom joins the footer — but only while the highlight is actually on such a row, so the
removal key advertises itself exactly where it acts (see
:attr:`~meshterm.ui.tui.select.SelectScreen.footer_hint`).
"""
delete_hint = (
"Del remove"
if any(isinstance(it, Choice) and it.deletable for it in items)
else ""
)
screen = SelectScreen(
title,
items,
default=default,
footer_hint=footer_hint,
delete_hint=delete_hint,
filterable=False,
)
screen.chrome = False
screen.banner = banner
screen.footnote = footnote
result = await self.run_screen(screen)
return None if result is CANCEL else result
async def confirm_startup(
self,
prompt: "str | Text",
*,
title: str = "",
confirm_label: str = "Remove",
banner: Optional[Any] = None,
footnote: Optional[str] = None,
backdrop_items: Optional[list] = None,
backdrop_default: Any = None,
backdrop_title: str = "Select a companion device",
footer_hint: str = "←→ choose · Enter select · Esc cancel",
) -> bool:
"""Confirm a destructive splash action with a Cancel/verb dialog (chromeless).
The startup-splash sibling of :meth:`button_dialog`: the same platform-dialog layout
— the safe *Cancel* on the left and the committing verb on the right and default, so
Enter commits and Esc backs out. Themed as data loss (the reserved red prompt and
border), since it only ever gates forgetting a remembered device — the splash sibling
of a ``destructive`` :meth:`button_dialog`.
When ``backdrop_items`` is given, the device list they describe is redrawn as the
chromeless base and the red confirm *floats over it* as a centred box (a modal popup
over the pushed backdrop, the way every other dialog behaves) — so removing a device
reads as a popup on top of the picker rather than a splash that replaces it. The
``backdrop_default`` row is pre-highlighted so the confirm reads as being about it.
Without ``backdrop_items`` the confirm draws as its own chromeless splash under
``banner`` (the fallback for a caller with no list to float over).
Args:
prompt: The question shown above the buttons.
title: Short heading shown in the dialog's border.
confirm_label: Label for the committing button (e.g. ``"Remove"``).
banner: Wordmark rows drawn above the box (as on the other startup splashes).
footnote: Muted line drawn below the box.
backdrop_items: The picker's rows to redraw behind the confirm; ``None`` falls
back to a standalone chromeless confirm splash.
backdrop_default: The row value to pre-highlight in the backdrop list.
backdrop_title: Heading for the backdrop list (the picker's own title).
footer_hint: Footer key hint.
Returns:
``True`` only when the user chose the committing button; ``False`` on Cancel/Esc.
"""
dialog = ButtonDialog(
prompt,
[("Cancel", False), (confirm_label, True)],
title=title,
default=1,
footer_hint=footer_hint,
prompt_style="err",
border_style="err",
)
if backdrop_items is None:
# No list to float over: draw the confirm as its own chromeless splash.
dialog.chrome = False
dialog.banner = banner
dialog.footnote = footnote
return await self.run_screen(dialog) is True
# Keep the picker on screen as the chromeless base and float the red confirm over it,
# so the removal confirm sits *on top of* the device list it acts on. The backdrop is
# a static redraw of the same rows (it never takes a key — the dialog above owns input).
backdrop = SelectScreen(
backdrop_title,
backdrop_items,
default=backdrop_default,
footer_hint="↑↓ move · Enter select · Esc quit",
delete_hint="Del remove",
filterable=False,
)
backdrop.chrome = False
backdrop.banner = banner
backdrop.footnote = footnote
self.push(backdrop)
try:
return await self.run_screen(dialog) is True
finally:
self.pop(backdrop)
async def notify_startup(
self,
renderable: RenderableType,
*,
title: str = "",
banner: Optional[Any] = None,
footnote: Optional[str] = None,
footer_hint: str = "Enter continue",
) -> None:
"""Show a chromeless message splash (banner above a boxed renderable) until dismissed."""
screen = ScrollScreen(renderable, title=title, footer_hint=footer_hint)
screen.chrome = False
screen.banner = banner
screen.footnote = footnote
await self.run_screen(screen)
async def busy_startup(
self,
message: str,
coro: Any,
*,
title: str = "",
banner: Optional[Any] = None,
footnote: Optional[str] = None,
interval: Optional[float] = None,
) -> Any:
"""Await ``coro`` while showing an animated spinner on the chromeless splash.
Keeps the startup splash on screen (same wordmark and box) and swaps its contents for
an ASCII spinner beside ``message`` while the awaited task runs, then returns the
task's result. A background timer advances the spinner and repaints every
``interval`` seconds; it is always cancelled and the splash popped before returning.
Args:
message: The line shown beside the spinner (e.g. "Talking to Wio on COM5…").
coro: The awaitable to run (e.g. a device smoke test).
title: Optional panel title for the splash box.
banner: Wordmark rows drawn above the box (as on the other startup splashes).
footnote: Muted line drawn below the box.
interval: Seconds between spinner frames. Defaults to the platform's cadence,
which is the point on a slow console: a repaint there costs more than this
loop used to wait between them, so a hardcoded rate spent the whole event
loop redrawing the spinner and starved the very work it was reporting on.
Returns:
Whatever ``coro`` resolves to.
"""
tick = spinner_interval() if interval is None else interval
screen = BusyScreen(message, title=title)
screen.chrome = False
screen.banner = banner
screen.footnote = footnote
self.push(screen)
async def animate() -> None:
while True:
await asyncio.sleep(tick)
screen.tick()
self.invalidate()
ticker = asyncio.ensure_future(animate())
try:
return await coro
finally:
ticker.cancel()
# Await the cancelled ticker so it is never garbage-collected while still
# pending: that surfaces as a screen-corrupting "Task was destroyed but it is
# pending!" loop error. The spinner is cosmetic, so any glitch is swallowed.
try:
await ticker
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001 - a spinner hiccup must never break startup
pass
self.pop(screen)
async def prompt_pin_startup(
self,
device_name: str,
*,
error: str = "",
help_text: str = "",
banner: Optional[Any] = None,
footnote: Optional[str] = None,
) -> Optional[str]:
"""Ask for a companion's Bluetooth PIN on the chromeless startup splash.
Drawn like :meth:`notify_startup` / :meth:`select_startup` — a bordered box centered
under ``banner`` with no status bars — so the PIN request is visually part of the same
device-selection flow. ``error`` is shown in the box on a re-ask after a rejected code.
Args:
device_name: The companion's display name, shown in the prompt.
error: A rejected-PIN message to display (empty on the first ask).
help_text: A muted hint under the field.
banner: Wordmark rows drawn above the box (as on the other startup splashes).
footnote: Muted line drawn below the box.
Returns:
The entered PIN, or ``None`` if the user pressed Esc to cancel.
"""
screen = PinDialog(device_name, error=error, help_text=help_text)
screen.chrome = False
screen.banner = banner
screen.footnote = footnote
result = await self.run_screen(screen)
return None if result is CANCEL else result
async def prompt_text_startup(
self,
title: str,
*,
prompt: str = "",
default: str = "",
validate: Optional[Validator] = None,
help_text: str = "",
banner: Optional[Any] = None,
footnote: Optional[str] = None,
) -> Optional[str]:
"""Ask for a line of text on the chromeless startup splash (e.g. a TCP host:port).
Drawn like :meth:`prompt_pin_startup` — a bordered :class:`TextScreen` centered under
``banner`` with no status bars — so entering a network address reads as part of the
same device-selection flow. ``validate`` blocks submission on a bad value the same way
the in-menu text prompt does.
Args:
title: Short heading shown in the dialog's border.
prompt: The instruction shown inside the box, above the field.
default: Prefilled text.
validate: Optional validator run on Enter; a returned string blocks submission.
help_text: A muted hint under the field.
banner: Wordmark rows drawn above the box (as on the other startup splashes).
footnote: Muted line drawn below the box.
Returns:
The entered text, or ``None`` if the user pressed Esc to cancel.
"""
screen = TextScreen(
title, prompt=prompt, default=default, validate=validate, help_text=help_text
)
screen.chrome = False
screen.banner = banner
screen.footnote = footnote
result = await self.run_screen(screen)
return None if result is CANCEL else result
async def reorder(self, title: str, labels: list[str]) -> list[int]:
"""Show a drag-with-arrows reorder screen; return the final order of row indices.
The Apply action row below the list commits the rearrangement; Back (or Esc)
cancels, which comes back as the original (identity) order so the caller
treats it as "no change".
"""
result = await self.run_screen(ReorderScreen(title, labels))
return list(range(len(labels))) if result is CANCEL else result
async def text(
self,
title: str,
*,
prompt: str = "",
default: str = "",
validate: Optional[Validator] = None,
help_text: str = "",
password: bool = False,
byte_limit: Optional[int] = None,
floating: bool = False,
) -> Optional[str]:
"""Show a text prompt; return the string or ``None`` if cancelled.
``floating`` guarantees the prompt draws as a centered popup even on an empty stack
— a modal step mid-flow (a remote-admin password) rather than a tool's full-frame
entry screen. It then floats over a blank base the way :meth:`button_dialog` and
:meth:`typed_confirm` do (see :meth:`_run_dialog_screen`); with a screen already
beneath it there is no difference, so leave it ``False`` for a prompt that is itself
a tool's primary screen (the Trace target's typed fallback).
``byte_limit`` puts the shared UTF-8 byte gauge on the field and blocks submission
past it — for a field that feeds a size-capped packet (see :class:`TextScreen`).
"""
screen = TextScreen(
title,
prompt=prompt,
default=default,
validate=validate,
help_text=help_text,
password=password,
byte_limit=byte_limit,
)
runner = self._run_dialog_screen if floating else self.run_screen
result = await runner(screen)
return None if result is CANCEL else result
async def confirm(self, title: str, *, default: bool = True) -> Optional[bool]:
"""Show a yes/no prompt; return the bool or ``None`` if cancelled."""
result = await self.run_screen(ConfirmScreen(title, default=default))
return None if result is CANCEL else result
async def button_dialog(
self,
prompt: "str | Text",
buttons: list[tuple[str, Any]],
*,
title: str = "",
default: int = 0,
keys: Optional[dict[str, Any]] = None,
footer_hint: str = "←→ choose · Enter select · Esc cancel",
prompt_style: str = "",
button_style: str = "selected",
button_idle_style: str = "muted",
border_style: str = "accent",
) -> Any:
"""Show a centered button dialog; return the chosen value or ``None`` if cancelled.
A reusable prompt-above-buttons dialog (see :class:`~meshterm.ui.tui.prompt.
ButtonDialog`): the colours, prompt, buttons, and single-key shortcuts are all
parametrised, so a caller can theme it (e.g. a destructive action in red) or wire
instant y/n keys. ``keys`` maps a shortcut character to the value it commits.
The prompt may be a pre-styled :class:`Text` (see the ButtonDialog docs).
"""
screen = ButtonDialog(
prompt,
buttons,
title=title,
default=default,
keys=keys,
footer_hint=footer_hint,
prompt_style=prompt_style,
button_style=button_style,
button_idle_style=button_idle_style,
border_style=border_style,
)
result = await self._run_dialog_screen(screen)
return None if result is CANCEL else result
async def _run_dialog_screen(self, screen: Screen) -> Any:
"""Run a floating dialog, guaranteeing it draws as a centered box, not full-frame.
On an empty stack a lone floating screen is drawn *as* the background — framed
chrome filling the terminal, no popup — so a blank base is pushed beneath it first
(the trick the reconnect dialog and the message popup already use) and popped once
it closes. With a background already present the dialog simply floats over it.
"""
backdrop: Optional[ScrollScreen] = None
if not self._stack:
backdrop = ScrollScreen("", floating=False, footer_hint="")
self.push(backdrop)
try:
return await self.run_screen(screen)
finally:
if backdrop is not None:
self.pop(backdrop)
async def typed_confirm(
self, warning: str, word: str, *, title: str = "Are you sure?"
) -> bool:
"""Gate a destructive action behind typing ``word``; return whether it was typed.
Shows the error-themed :class:`~meshterm.ui.tui.prompt.TypedConfirmDialog` and
collapses its result to a plain bool: ``True`` only when the user typed the word,
``False`` when they backed out with Esc.
"""
result = await self._run_dialog_screen(
TypedConfirmDialog(warning, word, title=title)
)
return result is True
async def autocomplete(
self,
title: str,
choices: list[str],
*,
prompt: str = "",
default: str = "",
validate: Optional[Validator] = None,
) -> Optional[str]:
"""Show a free-text prompt with suggestions; return text or ``None`` if cancelled."""
result = await self._run_dialog_screen(
AutocompleteScreen(
title, choices, prompt=prompt, default=default, validate=validate
)
)
return None if result is CANCEL else result
async def message_dialog(self, message: "Text | str", *, title: str = "") -> None:
"""Show a short outcome in a centered popup with a single OK button.
The lightweight acknowledgement counterpart of :meth:`scroll`: a one-line result
("✓ flood advertisement sent") doesn't warrant a full result window, so it floats
as a small dialog over whatever screen is beneath — Enter (OK) or Esc dismisses
it. The border takes the message's strongest tone (see :func:`_message_border`),
so an error pops red while a success stays in the standard accent. It delegates to
:meth:`button_dialog`, which floats it over a blank base when the stack is empty (a
tool run straight from the menu, which is popped while the tool executes).
Args:
message: The outcome to show — a pre-styled :class:`Text` (note markup
survives into the dialog) or a plain string.
title: Optional dialog heading (typically the tool or action name).
"""
await self.button_dialog(
message,
[("OK", "ok")],
title=title,
footer_hint="Enter OK",
border_style=_message_border(message),
)
async def scroll(
self, renderable: RenderableType, *, title: str = "", footer_hint: str = ""
) -> None:
"""Show a dismissable, scrollable view of a renderable (a result window)."""
screen = ScrollScreen(
renderable,
title=title,
footer_hint=footer_hint or "↑↓ PgUp/PgDn scroll · Esc close",
)
await self.run_screen(screen)
def progress(self, title: str = "Working") -> TuiProgress:
"""Return a progress context manager backed by a pushed :class:`ProgressScreen`."""
return TuiProgress(self, title)
@asynccontextmanager
async def busy_overlay(
self,
message: str = "",
*,
title: str = "",
interval: Optional[float] = None,
) -> AsyncIterator[BusyOverlay]:
"""Float a skeleton card on top of everything for the duration of a block.
Wrap a slow, screen-affecting operation — most usefully a device menu navigation,
which can otherwise sit on a blank frame while the companion answers — in::
async with session.busy_overlay("reading…", title="Nodes"):
await slow_work()
The card stands in for the screen being fetched: a title and the one-cell working
chip beside the caption. A background timer advances the chip,
fades the card in, and repaints while the block runs; it is always cleared and the
timer cancelled on exit, even on error. The card fades in from black rather than
popping in (see :attr:`BusyOverlay.brightness`), so a quick operation only paints a
near-black card and it never appears suddenly. It is drawn only in the gaps between
screens (see :meth:`_overlay_visible`), so it announces the wait without covering a
prompt the user is interacting with.
Args:
message: An optional caption drawn beside the working chip.
title: An optional heading naming the screen being fetched.
interval: Seconds between animation frames (also the fade's repaint cadence).
Defaults to the platform's cadence — see :meth:`busy_startup`, which this
shares a hazard with: the card animates *over* a device read, so a rate the
console can't sustain steals the loop from the read it is covering for.
Yields:
The live :class:`BusyOverlay`, in case the caller wants to update its caption.
"""
tick = spinner_interval() if interval is None else interval
# A nested busy_overlay keeps the outer one (the outermost wait owns the screen); its
# own body still runs, it just doesn't install a second card.
if self._overlay is not None:
yield self._overlay
return
overlay = BusyOverlay(message, title=title)
self._overlay = overlay
if self._overlay_visible():
self.invalidate() # start the fade-in promptly, before the first tick
async def animate() -> None:
while True:
await asyncio.sleep(tick)
overlay.tick()
# Only repaint when the card is actually on screen, so an overlay waiting
# behind a live prompt doesn't churn that prompt's repaints for nothing.
if self._overlay_visible():
self.invalidate()
ticker = asyncio.ensure_future(animate())
try:
yield overlay
finally:
ticker.cancel()
try:
await ticker
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001 - a cosmetic overlay must never break a flow
pass
self._overlay = None
self.invalidate()
# --- application lifecycle ------------------------------------------------
async def run(self, main: Any) -> None:
"""Run ``main`` (the menu loop) inside the full-screen application.
Starts the prompt_toolkit event loop, drives ``main`` as a background task on that
same loop, and exits the application when ``main`` returns. Any exception from
``main`` is re-raised after the loop unwinds.
Args:
main: The coroutine driving the session (typically the menu loop).
"""
self._app = self._build_app()
if get_platform().modifier_watch:
# The Shift watcher flips the F-key lane's labels live. It reports from its
# own thread; hop onto the app loop for the repaint. Failure to engage (no
# device, no permission) just leaves the lane static — see the module doc.
loop = asyncio.get_running_loop()
modifier_watch.start(lambda: loop.call_soon_threadsafe(self.invalidate))
box: dict[str, BaseException] = {}
task: dict[str, asyncio.Future] = {}
async def driver() -> None:
try:
await main
except asyncio.CancelledError:
raise
except BaseException as exc: # noqa: BLE001 - re-raised after the app unwinds
box["exc"] = exc
finally:
# ``is_running`` rather than ``not is_done``: prompt_toolkit clears its
# future on the way out, which makes ``is_done`` read False again once the
# app has already finished — and ``exit()`` on a finished app raises. This
# ``finally`` now also runs *after* the app is gone (the quit chords exit it
# from under us and ``run`` then cancels this task), so it has to tell "still
# up" from "already down" rather than "not yet finished".
if self._app is not None and self._app.is_running:
self._app.exit()
def pre_run() -> None:
task["driver"] = asyncio.ensure_future(driver())
# Don't let prompt_toolkit install its own loop exception handler: on any stray
# background-task error it prints a traceback and a "Press ENTER to continue..."
# prompt straight over the full-screen UI. With it disabled, asyncio's default
# handler logs such errors to the ``asyncio`` logger instead, which is routed to the
# file log (see :func:`meshterm.persistence.logging.configure_logging`) and never
# touches the screen. Errors from ``main`` still propagate via ``driver``/``box``.
await self._app.run_async(pre_run=pre_run, set_exception_handler=False)
# The app can also exit from *under* the driver: the quit chords (^Q/^C) call
# ``Application.exit`` straight from the key handler, so ``run_async`` returns while
# ``main`` is still parked on whatever screen was up. Left alone, that coroutine is
# simply abandoned mid-await and its ``finally`` never runs — which is where the
# history and chat runs are closed, the background services stopped, and the exit
# watchdog armed. Cancel it and wait for the unwind so quitting from anywhere tears
# down exactly as much as quitting from the menu does.
driver_task = task.get("driver")
if driver_task is not None and not driver_task.done():
driver_task.cancel()
try:
await driver_task
except asyncio.CancelledError:
pass
if "exc" in box:
raise box["exc"]
def _build_app(self) -> Application:
"""Construct the prompt_toolkit application, layout, and key bindings."""
base_control = ClusterTextControl(self._render_base, focusable=True)
base_window = Window(base_control, always_hide_cursor=True)
# One centered-box float per stacked dialog layer, bottom-to-top: each renders the
# k-th screen floating above the background (see :meth:`_float_layers`), so a dialog
# opened over an existing popup draws *over* it — both at their own size — instead of
# the lower one being stretched to fill the frame. A generous fixed pool covers any
# realistic nesting; the ConditionalContainer hides the layers not in use this frame.
dialog_floats = [
Float(
ConditionalContainer(
Window(
ClusterTextControl(
lambda i=i: self._render_float_layer(i)
),
always_hide_cursor=True,
),
filter=Condition(lambda i=i: len(self._float_layers()) > i),
)
)
for i in range(_MAX_DIALOG_LAYERS)
]
# The busy overlay is the last float, so it draws on top of every dialog float — the
# top of the z-order. It is a content-sized window (dont_extend_*) with no anchors, so
# the FloatContainer centres just its skeleton card over the screen rather than blanking it.
overlay_window = Window(
ClusterTextControl(self._render_overlay),
always_hide_cursor=True,
dont_extend_width=True,
dont_extend_height=True,
)
root = FloatContainer(
content=base_window,
floats=[
*dialog_floats,
Float(
ConditionalContainer(
overlay_window, filter=Condition(self._overlay_visible)
)
),
],
)
app = Application(
layout=Layout(root, focused_element=base_window),
key_bindings=self._key_bindings(),
full_screen=True,
mouse_support=False,
# Keeps the live monitor counter in the header ticking. Per-platform, so a host
# where an idle repaint is expensive can breathe more slowly between frames.
refresh_interval=get_platform().tick_s,
# Repaint as soon as the loop is free, rather than spinning the event loop for
# up to 10 ms first. prompt_toolkit's default postpone is there to protect a
# UI whose *own* output floods it with invalidations (its motivating case was
# a terminal multiplexer); here an invalidation is a keystroke or the 2 s
# header tick, so the delay buys nothing and was measured as a flat 3 ms on
# the PicoCalc and 10 ms on desktop, paid on every single key.
max_render_postpone_time=None,
input=self._input,
output=self._resolve_output(),
)
if fastrender.enabled():
# Swap in the row-diff renderer for plain full-screen frames. Built with the
# same arguments Application gave the stock one, so everything except the
# paint itself — CPR, alternate screen, mouse, cursor shape — is unchanged.
app.renderer = fastrender.FastRenderer(
app._merged_style,
app.output,
full_screen=True,
mouse_support=False,
cpr_not_supported_callback=app.cpr_not_supported_callback,
frame_source=self._plain_frame,
)
return app
def _resolve_output(self) -> Any:
"""The output the app renders to — optionally widened to reclaim the last column.
Only the *real* terminal (``self._output is None``, so prompt_toolkit would build its
own output) is wrapped, and only when :func:`_reclaim_last_column` says on: a test
that supplies its own output keeps the exact size it set, so headless rendering stays
deterministic. See :class:`_WidthExtendedOutput` for what the wrap does.
"""
if self._output is not None or not _reclaim_last_column():
return self._output
from prompt_toolkit.output.defaults import create_output
return _WidthExtendedOutput(create_output())
# --- rendering -----------------------------------------------------------
def _size(self) -> tuple[int, int]:
"""Return the current (cols, rows) of the terminal."""
size = self._app.output.get_size() # type: ignore[union-attr]
return max(20, size.columns), max(6, size.rows)
def base_body_size(self) -> tuple[int, int]:
"""Return the ``(width, height)`` in cells available to the base screen's body.
Mirrors the layout math in :func:`~meshterm.ui.tui.frame.compose_base` so a
full-screen screen (e.g. the map) can size its own content to fill the frame exactly,
without waiting a repaint to learn its height. *Both* of that function's branches:
a borderless platform swaps the panel's two border rows and four padding columns for
a single title-bar row, so a screen sizing itself against the bordered math there
would leave a row of the frame it was handed permanently blank.
Returns:
The inner content width and the body viewport height, both in character cells.
"""
from .render import render_lines
cols, rows = self._size()
header_h = len(render_lines(self._header(cols), cols, no_wrap=True))
if get_platform().frame_border:
# minus footer(1) and panel border(2); the border eats 4 columns too
return cols - 4, max(1, rows - header_h - 1 - 2)
return cols, max(1, rows - header_h - 1 - 1) # minus footer(1) and title bar(1)
def _base_index(self) -> int:
"""Stack index of the full-frame background screen.
The background is the top-most *non-floating* screen — a menu, map, or list that
fills the frame — and every floating dialog above it is drawn as a centered box
over it (see :meth:`_float_layers`). When the whole stack is floating (a tool
whose own primary screen is a floating select, e.g. Channels), the bottom screen
is the background: it is the one the dialogs above it should float over.
"""
for i in range(len(self._stack) - 1, -1, -1):
if not getattr(self._stack[i], "floating", False):
return i
return 0
def _base_screen(self) -> Optional[Screen]:
"""The screen drawn as the full-frame background, or ``None`` when the stack is empty."""
if not self._stack:
return None
return self._stack[self._base_index()]
def _float_layers(self) -> list[Screen]:
"""The floating dialogs stacked above the background, bottom-to-top.
Each is drawn as its own centered box over the ones beneath — so opening a dialog
over an existing popup leaves that popup at its own size rather than stretching it
to fill the frame (the single-float compositor's old failing).
"""
if not self._stack:
return []
return self._stack[self._base_index() + 1:]
def _has_float(self) -> bool:
"""Whether any dialog floats over the background this frame."""
return bool(self._float_layers())
def _emit(self, text: str, layer: str = "base") -> ANSI:
"""Wrap a composed frame as prompt_toolkit :class:`ANSI`, repainting whole *rows*
when it holds a glyph the terminal may draw narrower than pt reserves for it.
prompt_toolkit paints differentially: it rewrites only the cells that changed since
the last frame, and it steps the cursor *relative* to its own width model. That is
sound only while every glyph is one cell wide. A width-2 glyph the terminal draws in
a single cell (an emoji in a chat line, a menu icon) leaves everything to its right on
that row one column left of where pt thinks it is; a later paint that jumps into the
row — skipping the unchanged emoji — writes at pt's column, one past the content it
meant to overwrite, and the stale cell lingers.
The requirement that fixes is narrow: a row carrying such a glyph must be rewritten
*whole*, from column 0, so the terminal's own cursor advance re-lays it. It does not
need the screen erased, and it does not need the rows around it touched — pt steps
down a row with ``\\r\\n``, which returns the terminal to a true column 0 whatever the
drift above it, so the misalignment can never spread past the row it is on.
So the changed rows are rewritten and nothing else is (:meth:`_scrub_rows`). Three
things narrow it to that:
* **Nothing changed → nothing to do.** The app repaints on a 1 Hz timer to tick the
header's pulse, and an idle screen composes identically each time. pt writes no
cells, so its cursor cannot drift; the previous paint already left the terminal
aligned. (This alone is the flicker: erasing an emoji-bearing screen and rewriting
it identically, once a second.)
* **No wide glyph drawn → nothing to do.** The plain differential paint is exact.
* **Otherwise, only the rows that changed.** The background composes one line per
terminal row, so its diff maps straight onto rows — a ticking header repaints the
header, not the screen under it. A *float* is a centred box whose rows the layout
places, not us, so a dialog changing (or closing — see :meth:`_reconcile_layers`)
still falls back to dropping pt's cached frame, as does a frame whose height moved.
Those are user-driven and occasional; the timer is neither.
"""
entry = (text, _has_wide_glyph(text))
previous = self._layers.get(layer)
if previous == entry:
return ANSI(text)
self._layers[layer] = entry
if any(wide for _text, wide in self._layers.values()):
rows = _changed_rows(previous[0], text) if previous is not None else None
# The background's line i *is* terminal row i; nothing else can claim that.
if layer != "base" or rows is None or not self._scrub_rows(rows):
self._invalidate_last_frame()
return ANSI(text)
def _repaint_if_wide(self) -> None:
"""Upgrade this paint to a full repaint if any drawn layer holds a wide glyph."""
if any(wide for _text, wide in self._layers.values()):
self._invalidate_last_frame()
def _reconcile_layers(self) -> None:
"""Forget the layers this paint won't draw, and treat their leaving as a change.
A float is hidden by dropping its window from the layout, so a closing dialog simply
stops calling :meth:`_emit` — nothing would otherwise notice it had gone, and the
cells it gives back to the base would be rewritten piecemeal over a row the terminal
may be drawing shifted. Called at the top of the paint, from the base layer, since
the background is composed before the floats above it.
"""
live = {f"float{i}" for i in range(len(self._float_layers()))}
if self._base_screen() is not None:
live.add("base")
if self._overlay_visible():
live.add("overlay")
gone = [layer for layer in self._layers if layer not in live]
for layer in gone:
self._layers.pop(layer)
if gone:
self._repaint_if_wide()
def _render_base(self) -> ANSI:
"""Render the persistent frame around the background screen."""
self._reconcile_layers() # the paint starts here: the background composes first
cols, rows = self._size()
base = self._base_screen()
if base is None:
return ANSI("")
scrub = base.consume_edge_scrub()
if scrub:
self._scrub_right_columns(scrub)
# A chromeless base (the startup splash) forgoes the header/footer bars and is
# centered under its banner instead of stretched across the terminal.
if not base.chrome:
return self._emit(frame.compose_startup(base, cols, rows))
footer = self.top.footer_hint if self.top else base.footer_hint
lane = self._fkey_lane(self.top or base)
return self._emit(
frame.compose_base(self._header(cols), base, footer, cols, rows, footer_lane=lane)
)
def _plain_frame(self) -> Optional[str]:
"""The whole frame as rows, when this paint is one the fast path may take.
The background screen, with every floating dialog composited over it exactly where
prompt_toolkit's ``FloatContainer`` would centre it (see
:func:`~meshterm.ui.tui.frame.composite_float`) — so a confirm, a picker or the
packet viewer stays on the row-diff path rather than paying prompt_toolkit's whole
grid rebuild on every keystroke.
Answers ``None`` — meaning "let prompt_toolkit lay this one out" — for the two
frames we don't place ourselves: the busy overlay, which is a content-sized window
the float container measures, and an empty stack, which has no background at all.
Every layer still goes through its usual renderer (:meth:`_render_base`,
:meth:`_render_float_layer`), so the layer bookkeeping those keep is identical
whichever path the paint takes.
"""
if self._overlay_visible() or not self._stack:
return None
rows_out = self._render_base().value.split("\n")
layers = self._float_layers()
if layers:
cols, rows = self._size()
for index in range(len(layers)):
rows_out = frame.composite_float(
rows_out, self._render_float_layer(index).value, cols, rows
)
return "\n".join(rows_out)
@staticmethod
def _fkey_lane(active: Screen) -> Optional[Callable[[], Text]]:
"""A deferred F-key lane row for ``active``, or ``None`` on a platform without one.
Deferred because the frame resolves it *after* the body renders: a lane dims the
slots whose action would do nothing, and that reading comes from the scroll
metrics this paint is about to record (see :func:`~meshterm.ui.tui.fkeys.default_lane`).
"""
if not get_platform().footer_fkeys:
return None
return lambda: fkeys.lane_text(active.fkey_lane, shifted=modifier_watch.shift_down())
def _render_float_layer(self, index: int) -> ANSI:
"""Render the ``index``-th floating dialog (bottom-to-top) as a centered box."""
layers = self._float_layers()
if index >= len(layers):
return ANSI("")
cols, rows = self._size()
return self._emit(frame.compose_dialog(layers[index], cols, rows), f"float{index}")
def _overlay_visible(self) -> bool:
"""Whether the busy overlay should be painted this frame.
Gated on an empty screen stack so the card only appears in the "black screen" gaps a
device operation opens between screens (a menu navigation before the tool's first
prompt, say) and never buries a dialog the user is meant to be reading. It also stays
unpainted through the overlay's initial hold (:attr:`BusyOverlay.brightness` is 0), so
an operation that finishes within the hold shows nothing and never flashes.
"""
return self._overlay is not None and not self._stack and self._overlay.brightness > 0
def _render_overlay(self) -> ANSI:
"""Render the busy overlay's skeleton card (only when :meth:`_overlay_visible`)."""
if self._overlay is None:
return ANSI("")
return self._emit(self._overlay.render(), "overlay")
# --- input ---------------------------------------------------------------
def _key_bindings(self) -> KeyBindings:
"""Build the global key bindings that dispatch normalized actions to the top.
Every key — navigation, Ctrl chord, typed character, pasted run — funnels through
:meth:`_dispatch`, which is what lets the right-Ctrl rescue there cover the whole app
rather than the screen actions only.
"""
kb = KeyBindings()
def bind(key: Any, action: str) -> None:
@kb.add(key)
def _(event: Any, action: str = action) -> None: # noqa: ANN401
self._dispatch(action)
for key, action in _KEY_ACTIONS.items():
bind(key, action)
@kb.add(Keys.Any)
def _typed(event: Any) -> None: # noqa: ANN401
data = event.data
if not data:
return
if len(data) == 1 and data.isprintable():
self._dispatch("text", data)
elif len(data) > 1:
# A bracketed paste (Ctrl-V, right-click, Ctrl-Shift-V) arrives as one
# multi-character run — hand the whole thing to the top screen as a paste it
# can confirm and insert, rather than dropping it as the old len==1 guard did.
self._dispatch("paste", data)
return kb
def _dispatch(self, action: str, data: str = "") -> None:
"""Forward an action to the top screen and repaint.
Right Ctrl is read as Ctrl first, app-wide: a plain navigation key, or *any* bare
letter arriving as text, is promoted to its Ctrl chord while the right Ctrl key is
physically held (see :func:`_right_ctrl_down`, :data:`_CTRL_CHORDS`,
:data:`_CTRL_LETTER_CHORDS`) — a no-op when the console already reported the chord, and
the rescue when a layout-claimed right Ctrl stripped it to a bare character. Because
every binding funnels through here, the rescue covers the session's own chords too, not
just the screen actions: right Ctrl-V pastes into a compose line instead of typing a
``v``, right Ctrl-C quits.
The three session-level actions are answered here rather than forwarded — no screen
ever sees ``to_menu``, ``quit`` or ``paste_clipboard``.
The repaint keeps prompt_toolkit's fast differential paint; a frame carrying a glyph
the terminal may draw narrower than pt reserves for it (an emoji) upgrades itself to a
full repaint at compose time — see :meth:`_emit`.
"""
# An F-key resolves against the top screen's lane (see ui.tui.fkeys) into a
# normal screen action; an unassigned slot, or a platform without the lane,
# drops the press here.
if len(action) in (2, 3) and action[0] == "f" and action[1:].isdigit():
number = int(action[1:])
if number > 5:
# However this resolves, the code only exists because Shift was
# physically down for the MCU to emit it — latch the lane's shifted
# display against the release/re-assert flicker (see modifier_watch).
modifier_watch.note_shift_bank_key()
top = self.top or self._base_screen()
resolved = fkeys.action_for(top.fkey_lane, number) if top else None
if resolved is None:
return
action = resolved
# The key-state probe comes last in each test, so it only runs for a key that could
# be a chord at all — not on every keystroke.
if action in _CTRL_CHORDS and _right_ctrl_down():
action = _CTRL_CHORDS[action]
elif action == "text" and data.lower() in _CTRL_LETTER_CHORDS and _right_ctrl_down():
action, data = _CTRL_LETTER_CHORDS[data.lower()], ""
if action == "to_menu":
# Pop every frame at once (^W). Refused over a dialog or work in flight; see
# request_pop_all. The repaint below is still wanted either way — a refusal
# changes nothing, and an armed unwind is about to change everything.
self.request_pop_all()
self.invalidate()
return
if action == "quit":
# Straight out (^Q/^C), with no confirmation — deliberately, and not an
# oversight to be tidied up later. The quit dialog exists to catch *one Esc too
# many* at the main menu, where a single reflexive keystroke on the way out of
# something would otherwise end the session (JP, 2026-08-30). Neither of these
# chords can be pressed by accident, so there is nothing for a confirm to catch
# — and both are the escape hatch: a screen that is wedged, or a device read
# that is hanging, is exactly when a confirm dialog (itself a screen, which has
# to paint and take a key) would be the thing in the way. Leaving is still
# clean: ``run`` cancels the coroutine driving the app and waits for its unwind,
# so the history and chat runs close, the services stop, and the exit watchdog
# is armed, the same as quitting from the menu. What this route does *not* offer
# is the dialog's "Unpair & quit" — that stays a deliberate, menu-only choice.
if self._app is not None:
self._app.exit()
return
if action == "paste_clipboard":
# Some terminals deliver Ctrl-V as the literal control key — no bracketed-paste
# sequence, so no text on the event. Read the OS clipboard ourselves and hand the
# run to the top screen as a paste. Terminals that instead translate Ctrl-V into a
# bracketed paste never reach here — that lands in _typed as a multi-char run.
text = _read_clipboard()
if text:
self._dispatch("paste", text)
return
top = self.top
if top is not None:
top.handle(action, data)
self.invalidate()
__all__ = [
"TuiSession",
"Choice",
"Separator",
"SelectScreen",
"ReorderScreen",
"ScrollScreen",
]