Files
MeshTerm/meshterm/ui/surface.py
T
Jean-Pierre MartineauandClaude Opus 5 2dbae7af77 The splash slides the whole row, because the whole row is what gets cut
The device rows pinned their fixed lanes and slid only the hardware model
underneath them. The reasoning was borrowed from the Trophy case, where the
rank, date and score always fit and only the walk overflows -- there the lanes
really are the reader's place in a long list, and sliding them off would cost
the row its identity to read something the columns already told you.

This list inverts that. On a narrow terminal it is the device name and the
address that get cut, so pinning them pinned the truncation in place: the one
thing the arrow keys could not reach was the text somebody most wanted to
finish reading. A name is not a landmark when it is the thing being elided.

So no lane is pinned, and the row slides whole. That needed the scroll to be
asked for outright, because what normally turns it on is a row pinning a head
block, and none of these do any more -- `hscroll` travels from the picker
through the splash surface to the list, the same path `live` took.

The test that covered the old behaviour is rewritten rather than removed. It
was a fair test of a decision that has changed, and the argument for the new
one belongs where the old one was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 03:46:15 -04:00

972 lines
37 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The UI surface: one small API tools use for input and output, in either front-end.
Tools never touch ``questionary`` or a raw console anymore — they talk to ``ctx.ui``. Two
implementations back it:
* :class:`PlainUi` (scripted CLI): prints immediately and uses a Rich progress bar, so CLI
behavior is byte-for-byte what it was before the TUI existed.
* :class:`TuiUi` (interactive menu): collects a tool's output and presents it in a bounded,
scrollable result window — or, when the whole result is just a line or two of text
("✓ flood advertisement sent"), as a small centered popup with an OK button instead of
a full window (see :func:`_collapse_to_message`) — and routes every prompt/progress
through the full-screen :class:`~meshterm.ui.tui.session.TuiSession`.
The interactive prompt methods are only ever reached from a tool's ``prompt_params`` (menu
only), so :class:`PlainUi` leaves them unsupported.
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
from contextlib import asynccontextmanager
from typing import Any
from rich.console import Console, Group, RenderableType
from rich.text import Text
from .tui.session import TuiSession
#: A validator returns ``True`` when input is acceptable, or an error message to show.
Validator = Callable[[str], "bool | str"]
#: Buffered output no taller than this many lines qualifies for the message dialog —
#: the OK popup :meth:`TuiUi.present` shows instead of a full result window. Kept small:
#: a dialog is an acknowledgement, not a reading surface. It counts the lines the tool
#: *emitted*, before any wrapping: one sentence is one outcome however long it runs.
_DIALOG_MAX_LINES = 3
#: ... and no taller than this once wrapped to the box's width, which is what the box
#: actually has to draw. The two counts differ only for a long line, and that is the case
#: worth bounding: a three-line outcome stays a popup, while a note long enough to be a
#: page of reading opens the scrollable window it belongs in.
_DIALOG_MAX_WRAPPED = 8
#: What :class:`~meshterm.ui.tui.prompt.ButtonDialog` spends on chrome around its widest
#: line — its own ``dialog_width`` margin (panel padding, border, breathing room). Taken
#: off the platform's readable width, it leaves the cells a message may actually use.
_DIALOG_CHROME_CELLS = 12
def _dialog_wrap_cells() -> int:
"""How wide a line may be inside the message popup, on the active platform.
A dialog sizes to its widest line and the compositor caps that to the terminal, so a
line long enough to need the cap is a line clipped on a readable-width screen. Wrapping
to what fits *there* is what lets a long outcome — the sentence explaining why a read
failed — stay a popup at all rather than commandeering the whole frame.
"""
from ..platforms import get_platform
platform = get_platform()
return max(24, platform.readable_cols - platform.dialog_margin - _DIALOG_CHROME_CELLS)
def _wrapped(lines: list[Text], width: int) -> list[Text]:
"""Re-flow ``lines`` to ``width`` cells, keeping every span they carry.
A blank line wraps to nothing, and a blank line is content in an outcome block, so it
is kept as itself. The wrap leaves the break's trailing space on the line it ended,
which the dialog would then centre the line around — so each one is trimmed.
"""
from .tui.render import _console
console = _console(width)
out: list[Text] = []
for line in lines:
parts = list(line.wrap(console, width)) or [Text("")]
for part in parts:
part.rstrip() # in place; Rich's rstrip returns nothing
out.extend(parts)
return out
class _NullBusy:
"""The scripted surface's stand-in for a busy card: a caption that goes nowhere."""
def __init__(self) -> None:
"""Start with an empty caption; assigning to it is the whole of the contract."""
self.message = ""
def _collapse_to_message(buffered: list[RenderableType]) -> Text | None:
"""Collapse small, text-only buffered output into one wrapped dialog message.
This is the gate for :meth:`TuiUi.present`'s popup upgrade: output qualifies only
when everything buffered is plain note text (:class:`Text` — a table or panel from
``show()`` disqualifies the lot) totalling at most :data:`_DIALOG_MAX_LINES` emitted
lines, and at most :data:`_DIALOG_MAX_WRAPPED` once re-flowed to the box's width.
Styling (the ``[ok]``/``[warn]`` markup on outcome notes) is preserved in the joined
message, which is what lets the dialog frame take its tone from it.
The width is a **wrap**, not a rejection. A one-sentence outcome is an outcome to
acknowledge however long it runs, and sending it to the scrollable window for being
140 cells wide made the least interesting results the ones that took over the screen —
a tool that failed before it produced anything most of all.
Args:
buffered: The renderables collected since the last present.
Returns:
The lines joined into one :class:`Text`, or ``None`` when the output belongs in
the scrollable result window instead.
"""
if not buffered or not all(isinstance(item, Text) for item in buffered):
return None
lines: list[Text] = []
for item in buffered:
lines.extend(item.split("\n") or [item])
if len(lines) > _DIALOG_MAX_LINES:
return None
lines = _wrapped(lines, _dialog_wrap_cells())
if len(lines) > _DIALOG_MAX_WRAPPED:
return None
return Text("\n").join(lines)
class Ui:
"""Abstract UI surface. See :class:`PlainUi` and :class:`TuiUi` for the two backends."""
def show(self, *renderables: RenderableType) -> None:
"""Display one or more Rich renderables (tables, panels, text)."""
raise NotImplementedError
def note(self, markup: str) -> None:
"""Display a short line of Rich-markup text."""
raise NotImplementedError
def ack(self, markup: str) -> None:
"""Acknowledge that an action was carried out — in the menu only.
The distinction from :meth:`note` is who the line is for. A note is *output*: the
answer the caller asked for. An acknowledgement is reassurance that a thing
happened — "✓ device clock set", "✓ channel 2 = #general" — which a person watching
a screen needs and a script does not, because the exit status already said it and
the line would only be something to filter out of the real output.
Both surfaces show them; what differs is *where*. The menu prints them in its
result window. The CLI prints them on **stderr** (see :meth:`PlainUi.ack`), which
keeps the promise the dropping was made to keep — ``meshterm contacts > f`` still
catches only the answer — while giving the person at the prompt back the ✓ and the
count they were losing to a rule written for a redirect they were not doing.
"""
raise NotImplementedError
async def view(
self, renderable: RenderableType, *, title: str = "", footer_hint: str = ""
) -> None:
"""Show a renderable immediately in a dismissable window (prints in CLI mode)."""
raise NotImplementedError
async def present(self, *, title: str = "") -> None:
"""Flush any buffered output to the user (a no-op when output is immediate)."""
def discard(self) -> None:
"""Drop any buffered-but-unshown output (a no-op when output is immediate)."""
def progress(self, title: str = "Working"): # noqa: ANN201 - context manager, varies by backend
"""Return a progress context manager exposing ``add_task``/``advance``/``update``."""
raise NotImplementedError
def busy_overlay(self, message: str = "", *, title: str = ""): # noqa: ANN201 - async CM, varies by backend
"""Return an async context manager that floats a "working" skeleton while its block runs.
In the interactive menu this shows the top-most skeleton card (see
:meth:`~meshterm.ui.tui.session.TuiSession.busy_overlay`), used to cover the lag of a
Bluetooth operation that would otherwise leave the screen blank. In scripted CLI mode
it does nothing (there is no full-screen surface to float over).
"""
raise NotImplementedError
def busy_dialog(self, message: str = "", *, title: str = ""): # noqa: ANN201 - async CM, varies by backend
"""Return an async context manager that floats a **modal** busy card over a screen.
The counterpart to :meth:`busy_overlay`, for slow work a *screen* starts rather than
work that happens in the gap between screens. The overlay is not a screen at all: it
draws only on an empty stack and it takes no keys, so a hub that stays pushed while
it works gets neither the card nor the protection. This one is pushed and modal — see
:meth:`~meshterm.ui.tui.session.TuiSession.busy_dialog` for what that buys. In
scripted CLI mode it does nothing, as the overlay does.
"""
raise NotImplementedError
async def select(
self,
title: str,
items: list,
*,
prompt: str = "",
default: Any = None,
filterable: bool = True,
delete_hint: str = "",
floating: bool = False,
) -> Any:
"""Prompt the user to choose one item; return its value or ``None`` if cancelled.
``prompt`` draws an instruction inside the popup, above the list. Pass
``filterable=False`` for a short, fixed list so a stray key can't narrow (and
resize) it. ``delete_hint`` (with rows marked deletable) enables the Delete
key's remove flow, and ``floating`` keeps a lead-in question drawn as a box even
with nothing under it — see :meth:`~meshterm.ui.tui.session.TuiSession.select`.
"""
raise NotImplementedError
async def select_startup(
self,
title: str,
items: list,
*,
default: Any = None,
banner: Any = None,
footnote: str | None = None,
footer_hint: str | None = None,
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
hscroll: bool | None = None,
) -> Any:
"""Choose one item on a chromeless startup splash; ``None`` if skipped.
``live`` is work to run while the splash is up, handed a ``redraw(items)`` that
swaps the rows under the reader and repaints -- the device picker's rescan. It is
cancelled when the splash resolves.
``keys`` declares bare-key shortcuts the splash answers, resolving with a
:class:`~meshterm.ui.tui.select.KeyRequest` (the picker's hide/show-all pair), and
``key_hint`` names them per highlighted row so the footer only advertises a key
where it would act. ``footer_hint`` replaces the base sentence.
"""
raise NotImplementedError
async def confirm_startup(
self,
prompt: str | Text,
*,
title: str = "",
confirm_label: str = "Remove",
banner: Any = None,
footnote: str | None = None,
backdrop_items: list | None = None,
backdrop_default: Any = None,
) -> bool:
"""Confirm a destructive action on the startup splash; ``True`` only if committed.
``backdrop_items`` (the picker's rows) floats the confirm over a redrawn device list;
``backdrop_default`` pre-highlights the row it acts on.
"""
raise NotImplementedError
async def notify_startup(
self,
renderable: RenderableType,
*,
title: str = "",
banner: Any = None,
footnote: str | None = None,
) -> None:
"""Show a message on a chromeless startup splash until the user dismisses it."""
raise NotImplementedError
async def busy_startup(
self,
message: str,
coro: Any,
*,
title: str = "",
banner: Any = None,
footnote: str | None = None,
) -> Any:
"""Await ``coro`` while showing a spinner on the startup splash; return its result."""
raise NotImplementedError
async def prompt_pin_startup(
self,
device_name: str,
*,
error: str = "",
help_text: str = "",
banner: Any = None,
footnote: str | None = None,
) -> str | None:
"""Ask for a Bluetooth companion's pairing PIN on the splash; ``None`` if cancelled."""
raise NotImplementedError
async def prompt_text_startup(
self,
title: str,
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
help_text: str = "",
banner: Any = None,
footnote: str | None = None,
) -> str | None:
"""Ask for a line of text on a chromeless startup splash; ``None`` if cancelled."""
raise NotImplementedError
async def reorder(self, title: str, labels: list[str]) -> list[int]:
"""Let the user rearrange rows with the arrows; return the new order of row indices."""
raise NotImplementedError
async def text(
self,
title: str,
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
help_text: str = "",
password: bool = False,
floating: bool = False,
) -> str | None:
"""Prompt for a line of text; return it or ``None`` if cancelled.
Keep ``title`` short (it heads the popup's border) and put the question/instruction
in ``prompt`` (drawn above the field), so a text popup reads like the button dialogs.
``floating`` forces the prompt to draw as a centered popup even with nothing beneath
it (a mid-flow modal such as a remote-admin password) instead of filling the frame;
surfaces without a screen stack ignore it.
"""
raise NotImplementedError
async def confirm(self, title: str, *, default: bool = True) -> bool | None:
"""Prompt yes/no; return the answer or ``None`` if cancelled."""
raise NotImplementedError
async def dialog(
self,
prompt: str | Text,
buttons: list[tuple[str, Any]],
*,
title: str = "",
default: int = 0,
keys: dict[str, Any] | None = None,
danger: bool = False,
destructive: bool = False,
) -> Any:
"""Show a centered button dialog; return the chosen value or ``None`` on Esc.
The general choose-one popup (a prompt above a row of buttons). Two escalating
cautionary tiers theme the prompt and border: ``danger`` (amber) for a disruptive
choice — discarding edits, a reboot — and ``destructive`` (the reserved error red)
for irreversible data loss, so a delete confirm reads red like its typed-confirm
sibling. Buttons follow the platform-dialog convention: the safe way out sits on
the left and the committing action on the right, which is also the sensible
``default`` so Enter commits it while Esc always backs out.
A pre-styled :class:`~rich.text.Text` prompt is drawn as it is, so it can name a
node in its own colours; the tier then themes only the border, and the prompt's
prose carries the tier's colour itself.
"""
raise NotImplementedError
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."""
raise NotImplementedError
async def autocomplete(
self,
title: str,
choices: list[str],
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
) -> str | None:
"""Prompt for free text with suggestions; return it or ``None`` if cancelled."""
raise NotImplementedError
async def path(self, title: str, *, prompt: str = "", default: str = "") -> str | None:
"""Prompt for a filesystem path; return it or ``None`` if cancelled."""
raise NotImplementedError
class PlainUi(Ui):
"""CLI surface: print straight to the scripted console; interactive prompts are unsupported.
The console it prints on emits no colour, wraps nothing, and trims trailing whitespace
(:func:`meshterm.ui.script.console`), and everything a tool hands over passes through
:func:`~meshterm.ui.script.flatten` on the way out, so no border, box or hoisted title
reaches stdout. That fold is the net, not the design: a surface a script is meant to
read is *written* in the CLI's own vocabulary — see :mod:`meshterm.ui.script`.
"""
def __init__(self, console: Console) -> None:
"""Bind the surface to a Rich console.
Args:
console: The console tool output is printed to.
"""
self.console = console
def show(self, *renderables: RenderableType) -> None:
"""Print each renderable immediately, unframed (see :func:`~meshterm.ui.script.flatten`)."""
from . import script
for renderable in renderables:
for item in script.flatten(renderable):
self.console.print(item)
def note(self, markup: str) -> None:
"""Print a markup line to the console immediately, as plain text.
The parameter is markup — that is the shared surface's contract, and forty-odd
callers write it — but the scripted console does not interpret markup: a node
broadcasts its own name, and Rich would read ``[...]`` in one as a style tag. So
the tags are resolved *here* rather than by the console, and what reaches stdout
is the text they were wrapping. Printing them raw put ``[muted]`` and ``[/muted]``
around the private key that ``config export-key > key.hex`` is supposed to be the
whole content of.
Malformed markup — which is what a *name* holding a bracket looks like — is not an
error to report but a string to print, so it falls through verbatim.
"""
from rich.errors import MarkupError
from rich.markup import render
try:
self.console.print(render(markup).plain)
except MarkupError:
self.console.print(markup)
def ack(self, markup: str) -> None:
"""Print it on stderr, where everything *about* the run goes.
These used to be dropped, and the reason was sound as far as it went: an
acknowledgement is not the answer, and a caller redirecting stdout must catch only
the answer. But stdout is not the only stream — putting them on stderr honours that
rule exactly, and a person watching a five-minute ``config restore`` gets the
setting-by-setting ✓ they had no way to see.
The markup is resolved here rather than by the console, as :meth:`note` explains,
and a *name* holding a square bracket is not an error to report but a string to
print — so malformed markup falls through verbatim.
"""
from rich.errors import MarkupError
from rich.markup import render
from . import script
try:
line: Text | str = render(markup)
except MarkupError:
line = markup
script.stderr_console().print(line, highlight=False)
async def view(
self, renderable: RenderableType, *, title: str = "", footer_hint: str = ""
) -> None:
"""Print the renderable immediately, unframed (there is no windowing in CLI mode)."""
from . import script
for item in script.flatten(renderable):
self.console.print(item)
def progress(self, title: str = "Working"): # noqa: ANN201
"""Return a progress bar drawn on stderr, so it never lands in piped output.
A progress bar is a courtesy to someone watching a long command run, and noise in
anything reading the command's output. stderr is where it belongs: visible in a
terminal, absent from ``meshterm contacts > contacts.txt``. Rich also draws
nothing at all when stderr is not a terminal, so a fully redirected run is silent.
"""
from . import script
from .widgets import make_progress
console = script.stderr_console()
progress = make_progress(console)
progress.disable = not console.is_terminal
return progress
@asynccontextmanager
async def busy_overlay(self, message: str = "", *, title: str = "") -> AsyncIterator[None]:
"""Do nothing: the scripted CLI has no full-screen surface to float a skeleton over."""
yield
@asynccontextmanager
async def busy_dialog(self, message: str = "", *, title: str = "") -> AsyncIterator[_NullBusy]:
"""Do nothing: there is no screen to interrupt and no keyboard to take.
Still yields something with a settable caption, because a caller that retitles the
card mid-batch must not have to ask which surface it is running on.
"""
yield _NullBusy()
def _no_prompt(self) -> RuntimeError:
"""Build the error raised if a rich prompt is reached on the non-interactive path."""
return RuntimeError("interactive prompts are only available in the menu")
async def select(
self,
title: str,
items: list,
*,
prompt: str = "",
default: Any = None,
filterable: bool = True,
delete_hint: str = "",
floating: bool = False,
) -> Any:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
async def select_startup(
self,
title: str,
items: list,
*,
default: Any = None,
banner: Any = None,
footnote: str | None = None,
footer_hint: str | None = None,
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
hscroll: bool | None = None,
) -> Any:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
async def confirm_startup(
self,
prompt: str | Text,
*,
title: str = "",
confirm_label: str = "Remove",
banner: Any = None,
footnote: str | None = None,
backdrop_items: list | None = None,
backdrop_default: Any = None,
) -> bool:
"""Unsupported in scripted CLI mode — the picker splash is interactive-only."""
raise self._no_prompt()
async def notify_startup(
self,
renderable: RenderableType,
*,
title: str = "",
banner: Any = None,
footnote: str | None = None,
) -> None:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
async def busy_startup(
self,
message: str,
coro: Any,
*,
title: str = "",
banner: Any = None,
footnote: str | None = None,
) -> Any:
"""No splash in scripted CLI mode; just await the task and return its result."""
return await coro
async def prompt_pin_startup(
self,
device_name: str,
*,
error: str = "",
help_text: str = "",
banner: Any = None,
footnote: str | None = None,
) -> str | None:
"""Unsupported in scripted CLI mode — a PIN must be supplied non-interactively.
The scripted path can't pop a dialog, so a PIN-protected device is handled by the
clean ``DeviceAuthenticationError`` message (pass ``--ble-pin``) rather than a prompt.
"""
raise self._no_prompt()
async def prompt_text_startup(
self,
title: str,
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
help_text: str = "",
banner: Any = None,
footnote: str | None = None,
) -> str | None:
"""Unsupported in scripted CLI mode — a network endpoint comes from ``--tcp`` instead."""
raise self._no_prompt()
async def reorder(self, title: str, labels: list[str]) -> list[int]:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
async def text(
self,
title: str,
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
help_text: str = "",
password: bool = False,
floating: bool = False,
) -> str | None:
"""Prompt on the terminal (line editor / getpass), re-asking until valid.
A few tools (e.g. a remote-admin password) can legitimately prompt from a scripted
run when no flag was supplied, so this stays functional on the CLI. An in-body
``prompt`` (used by the interactive popups) is printed once as a lead-in line here.
``floating`` is a full-screen-popup nicety with no meaning on the plain terminal, so
it is accepted and ignored.
Returns:
The entered string, or ``None`` on EOF / interrupt.
"""
import getpass
if prompt:
self.console.print(prompt)
head = f"{title} " if not default else f"{title} [{default}] "
prompt = head
while True:
try:
raw = getpass.getpass(prompt) if password else input(prompt)
except (EOFError, KeyboardInterrupt):
return None
value = raw if raw != "" else default
if validate is not None:
result = validate(value)
if result is not True:
self.console.print(f"[err]{result}[/err]")
continue
return value
async def confirm(self, title: str, *, default: bool = True) -> bool | None:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
async def dialog(
self,
prompt: str | Text,
buttons: list[tuple[str, Any]],
*,
title: str = "",
default: int = 0,
keys: dict[str, Any] | None = None,
danger: bool = False,
destructive: bool = False,
) -> Any:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
async def typed_confirm(self, warning: str, word: str, *, title: str = "Are you sure?") -> bool:
"""Unsupported in scripted CLI mode (destructive CLI commands gate on ``--yes``)."""
raise self._no_prompt()
async def autocomplete(
self,
title: str,
choices: list[str],
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
) -> str | None:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
async def path(self, title: str, *, prompt: str = "", default: str = "") -> str | None:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
class TuiUi(Ui):
"""Interactive surface: buffer output for a result window; route prompts to the session."""
def __init__(self, session: TuiSession) -> None:
"""Bind the surface to a running session.
Args:
session: The full-screen session that renders prompts and windows.
"""
self.session = session
self._buffer: list[RenderableType] = []
# --- output --------------------------------------------------------------
def show(self, *renderables: RenderableType) -> None:
"""Collect renderables for the next result window."""
self._buffer.extend(renderables)
def note(self, markup: str) -> None:
"""Collect a markup line for the next result window."""
self._buffer.append(Text.from_markup(markup))
def ack(self, markup: str) -> None:
"""Collect an acknowledgement — on this surface, exactly a note."""
self.note(markup)
async def present(self, *, title: str = "") -> None:
"""Show everything buffered since the last present; window or popup to fit.
A short, text-only outcome (a line or two of notes — "✓ flood advertisement
sent") floats as a centered OK dialog over the current screen, so a one-line
result never commandeers the whole frame; anything bigger, or anything holding
a table/panel, opens the bounded scrollable result window as before (see
:func:`_collapse_to_message` for the exact gate). Clears the buffer afterward.
Does nothing if nothing was buffered (e.g. a tool that only produced a file
artifact and an empty message).
Args:
title: Heading for the result window or popup.
"""
if not self._buffer:
return
buffered = self._buffer
self._buffer = []
message = _collapse_to_message(buffered)
if message is not None:
await self.session.message_dialog(message, title=title)
return
body = buffered[0] if len(buffered) == 1 else Group(*buffered)
await self.session.scroll(body, title=title)
def discard(self) -> None:
"""Drop any buffered output without showing it."""
self._buffer = []
async def view(
self, renderable: RenderableType, *, title: str = "", footer_hint: str = ""
) -> None:
"""Show a renderable immediately in a dismissable scroll window."""
await self.session.scroll(renderable, title=title, footer_hint=footer_hint)
def progress(self, title: str = "Working"): # noqa: ANN201
"""Return a progress dialog context manager for the session."""
return self.session.progress(title)
def busy_overlay(self, message: str = "", *, title: str = ""): # noqa: ANN201
"""Float the session's top-most skeleton card while the wrapped block runs."""
return self.session.busy_overlay(message, title=title)
def busy_dialog(self, message: str = "", *, title: str = ""): # noqa: ANN201
"""Push the session's modal busy card over the current screen while the block runs."""
return self.session.busy_dialog(message, title=title)
# --- input ---------------------------------------------------------------
async def select(
self,
title: str,
items: list,
*,
prompt: str = "",
default: Any = None,
filterable: bool = True,
delete_hint: str = "",
floating: bool = False,
) -> Any:
"""Delegate to the session's select screen."""
return await self.session.select(
title,
items,
prompt=prompt,
default=default,
filterable=filterable,
delete_hint=delete_hint,
floating=floating,
)
async def select_startup(
self,
title: str,
items: list,
*,
default: Any = None,
banner: Any = None,
footnote: str | None = None,
footer_hint: str | None = None,
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
hscroll: bool | None = None,
) -> Any:
"""Delegate to the session's chromeless startup select splash."""
return await self.session.select_startup(
title,
items,
default=default,
banner=banner,
footnote=footnote,
keys=keys,
key_hint=key_hint,
live=live,
hscroll=hscroll,
**({} if footer_hint is None else {"footer_hint": footer_hint}),
)
async def confirm_startup(
self,
prompt: str | Text,
*,
title: str = "",
confirm_label: str = "Remove",
banner: Any = None,
footnote: str | None = None,
backdrop_items: list | None = None,
backdrop_default: Any = None,
) -> bool:
"""Delegate to the session's startup confirm dialog (floated over the picker)."""
return await self.session.confirm_startup(
prompt,
title=title,
confirm_label=confirm_label,
banner=banner,
footnote=footnote,
backdrop_items=backdrop_items,
backdrop_default=backdrop_default,
)
async def notify_startup(
self,
renderable: RenderableType,
*,
title: str = "",
banner: Any = None,
footnote: str | None = None,
) -> None:
"""Delegate to the session's chromeless startup message splash."""
await self.session.notify_startup(renderable, title=title, banner=banner, footnote=footnote)
async def busy_startup(
self,
message: str,
coro: Any,
*,
title: str = "",
banner: Any = None,
footnote: str | None = None,
) -> Any:
"""Delegate to the session's animated-spinner startup splash."""
return await self.session.busy_startup(
message, coro, title=title, banner=banner, footnote=footnote
)
async def prompt_pin_startup(
self,
device_name: str,
*,
error: str = "",
help_text: str = "",
banner: Any = None,
footnote: str | None = None,
) -> str | None:
"""Delegate to the session's startup PIN dialog."""
return await self.session.prompt_pin_startup(
device_name, error=error, help_text=help_text, banner=banner, footnote=footnote
)
async def prompt_text_startup(
self,
title: str,
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
help_text: str = "",
banner: Any = None,
footnote: str | None = None,
) -> str | None:
"""Delegate to the session's chromeless startup text dialog."""
return await self.session.prompt_text_startup(
title,
prompt=prompt,
default=default,
validate=validate,
help_text=help_text,
banner=banner,
footnote=footnote,
)
async def reorder(self, title: str, labels: list[str]) -> list[int]:
"""Delegate to the session's reorder screen."""
return await self.session.reorder(title, labels)
async def text(
self,
title: str,
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
help_text: str = "",
password: bool = False,
byte_limit: int | None = None,
floating: bool = False,
) -> str | None:
"""Delegate to the session's text screen."""
return await self.session.text(
title,
prompt=prompt,
default=default,
validate=validate,
help_text=help_text,
password=password,
byte_limit=byte_limit,
floating=floating,
)
async def confirm(self, title: str, *, default: bool = True) -> bool | None:
"""Delegate to the session's confirm screen."""
return await self.session.confirm(title, default=default)
async def dialog(
self,
prompt: str | Text,
buttons: list[tuple[str, Any]],
*,
title: str = "",
default: int = 0,
keys: dict[str, Any] | None = None,
danger: bool = False,
destructive: bool = False,
) -> Any:
"""Delegate to the session's button dialog, in the caution tier the caller asked for.
``danger`` themes the frame cautionary; ``destructive`` uses the reserved error
red, and is only for irreversible data loss.
"""
tier = "err" if destructive else "warn" if danger else ""
return await self.session.button_dialog(
prompt,
buttons,
title=title,
default=default,
keys=keys,
prompt_style=tier,
border_style=tier or "accent",
)
async def typed_confirm(self, warning: str, word: str, *, title: str = "Are you sure?") -> bool:
"""Delegate to the session's typed-confirmation dialog."""
return await self.session.typed_confirm(warning, word, title=title)
async def autocomplete(
self,
title: str,
choices: list[str],
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
) -> str | None:
"""Delegate to the session's autocomplete screen."""
return await self.session.autocomplete(
title, choices, prompt=prompt, default=default, validate=validate
)
async def path(self, title: str, *, prompt: str = "", default: str = "") -> str | None:
"""Prompt for a path as free text (with the current value prefilled)."""
return await self.session.text(
title, prompt=prompt, default=default, help_text="filesystem path"
)