Files
Jean-Pierre MartineauandClaude Opus 5.5 45697db856 Borders are grey
A plain border, the one with no caution tier, was the indigo accent: one
more colour on every screen, and it meant nothing. It is now its own
theme style, border, in the grey of muted, with title.border and
hint.border beside it; the amber and red tiers keep their colours. That
covers the base frame, every dialog, the handhelds' title bar, the tab
strip, and the CLI's panels. The title bar's clip arrow for a direction
with nothing more now dims to faint, since the border and muted are the
same grey. On the PicoCalc the border takes the light grey slot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 23:21:24 -04:00

1000 lines
38 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The output surface: one small API that tools use for input and output, in either front end.
Tools do not use ``questionary`` or a raw console. They talk to ``ctx.ui``. Two
implementations are behind it:
* :class:`PlainUi` (the scripted CLI) prints at once and uses a Rich progress bar. Thus the
behaviour of the CLI is exactly the same, byte for byte, as before the TUI existed.
* :class:`TuiUi` (the interactive menu) collects the output of a tool and presents it in a
scrollable result screen of limited height. If the whole result is only a line or two
of text ("✓ flood advertisement sent"), it presents the result in a small centred
dialog with an OK button and not on a full result screen (refer to
:func:`_collapse_to_message`). It sends each prompt and each progress display through
the full-screen :class:`~meshterm.ui.tui.session.TuiSession`.
Only the ``prompt_params`` of a tool (menu only) can reach the interactive prompt methods,
so :class:`PlainUi` does not support them.
"""
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`` if the input is acceptable, or an error message to show.
Validator = Callable[[str], "bool | str"]
#: Buffered output of this number of lines or less can use the message dialog. This is the
#: OK dialog that :meth:`TuiUi.present` shows instead of a full result screen. The number
#: is small, because a dialog is an acknowledgement and not a place to read. The count is
#: the lines that the tool *emitted*, before any wrapping. One sentence is one outcome,
#: and its length does not change that.
_DIALOG_MAX_LINES = 3
#: ... and this number of lines or less after the text wraps to the width of the box. This
#: is what the box must draw. The two counts are different only for a long line, and this
#: is the case that needs a limit. An outcome of three lines stays in a dialog. A note that
#: is long enough to be a page of text opens the scrollable result screen, where it belongs.
_DIALOG_MAX_WRAPPED = 8
#: The number of cells that :class:`~meshterm.ui.tui.prompt.ButtonDialog` uses for the
#: chrome around its widest line: its own ``dialog_width`` margin (the padding of the
#: panel, the border, and some space). If you subtract it from the readable width of the
#: platform, you get the number of cells that a message can use.
_DIALOG_CHROME_CELLS = 12
def _dialog_wrap_cells() -> int:
"""The maximum width of a line in the message dialog, on the active platform.
A dialog has the width of its widest line, and the compositor limits that width to the
terminal. A line that is long enough to need the limit is clipped on a screen of the
readable width. A long outcome, for example the sentence that explains why a read
failed, can stay in a dialog only if the code wraps it to the width that fits there.
If it does not, the outcome takes 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]:
"""Wrap ``lines`` again to ``width`` cells, and keep each span that they have.
A blank line wraps to nothing, but a blank line is content in an outcome block, so the
function keeps it as it is. The wrap leaves the trailing space of the break on the line
that it ended. If the line keeps that space, the dialog centres the line with the space.
Thus the function trims each line.
"""
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 substitute of the scripted CLI for a busy card: a caption that goes nowhere."""
def __init__(self) -> None:
"""Start with an empty caption. The only contract is that a caller can assign to it."""
self.message = ""
def _collapse_to_message(buffered: list[RenderableType]) -> Text | None:
"""Collapse a small buffered output of only text into one wrapped dialog message.
This is the gate for the upgrade to a dialog in :meth:`TuiUi.present`. The output
qualifies only if all the buffered items are plain note text (:class:`Text`). A table
or a panel from ``show()`` makes the whole output not qualify. The total must be at
most :data:`_DIALOG_MAX_LINES` emitted lines, and at most :data:`_DIALOG_MAX_WRAPPED`
lines after the text is wrapped again to the width of the box. The function keeps the
styling (the ``[ok]`` and ``[warn]`` markup on outcome notes) in the joined message.
Thus the frame of the dialog can take its tone from the styling.
The width causes a **wrap**, not a rejection. An outcome of one sentence is an outcome
to acknowledge, and its length does not change that. The code once sent such an outcome
to the scrollable result screen because it was 140 cells wide. Then the least interesting
results took over the screen. The worst case was a tool that failed before it made any
output.
Args:
buffered: The renderables that were collected since the last present.
Returns:
The lines joined into one :class:`Text`, or ``None`` if the output belongs in the
scrollable result screen.
"""
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 output surface. :class:`PlainUi` and :class:`TuiUi` are the two backends."""
def show(self, *renderables: RenderableType) -> None:
"""Show one or more Rich renderables (tables, panels, text)."""
raise NotImplementedError
def note(self, markup: str) -> None:
"""Show a short line of text in Rich markup."""
raise NotImplementedError
def ack(self, markup: str) -> None:
"""Acknowledge that an action was done. In the menu only.
The difference from :meth:`note` is the person for whom the line is. A note is
*output*: the answer that the caller asked for. An acknowledgement tells the user
that something occurred, for example "✓ device clock set" or "✓ channel 2 =
#general". A person who looks at a screen needs it. A script does not need it,
because the exit status already says it. For a script the line is only something to
remove from the real output.
Both faces show acknowledgements, but in different places. The menu prints them in
its result screen. The CLI prints them on **stderr** (refer to
:meth:`PlainUi.ack`). Thus the CLI keeps the promise that was the reason for the
removal: ``meshterm contacts > f`` still catches only the answer. Also, the person
at the prompt gets back the ✓ and the count that a rule for a redirect took away,
and that person did not use a redirect.
"""
raise NotImplementedError
async def view(
self, renderable: RenderableType, *, title: str = "", footer_hint: str = ""
) -> None:
"""Show a renderable at once on a screen that the user can close (CLI mode prints it)."""
raise NotImplementedError
async def present(self, *, title: str = "") -> None:
"""Flush all buffered output to the user. It does nothing if the output is immediate."""
def discard(self) -> None:
"""Remove buffered output that was not shown. It does nothing if output is immediate."""
def progress(self, title: str = "Working"): # noqa: ANN201 - context manager, varies by backend
"""Return a progress context manager with ``add_task``, ``advance``, and ``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 skeleton card at the top (refer to
:meth:`~meshterm.ui.tui.session.TuiSession.busy_overlay`). It covers the delay of a
Bluetooth operation. Without it, the screen would be blank. In the scripted CLI it
does nothing, because there is no full-screen display 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.
It is the counterpart of :meth:`busy_overlay`. Use it for slow work that a *screen*
starts, and use the overlay for work that occurs in the gap between screens. The
overlay is not a screen. It draws only on an empty stack, and it takes no keys. Thus
a hub that stays pushed while it works gets no card and no protection. The busy
dialog is pushed and modal. Refer to
:meth:`~meshterm.ui.tui.session.TuiSession.busy_dialog` for the result of that. In
the scripted CLI it does nothing, the same as the overlay.
"""
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:
"""Ask the user to select one item. Return its value, or ``None`` if the user cancels.
``prompt`` draws an instruction in the dialog, above the list. For a short, fixed
list, pass ``filterable=False``. Then a key press by mistake cannot make the list
narrower (and change its size). ``delete_hint`` (with rows marked as deletable)
turns on the remove flow of the Delete key. ``floating`` keeps a lead-in question
drawn as a box, also when there is nothing under it. Refer to
: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:
"""Select one item on a startup splash that has no chrome. Return ``None`` if skipped.
``live`` is work to run while the splash is up. It gets a ``redraw(items)`` function
that replaces the rows under the user and paints again. The rescan of the device
picker is an example. The code cancels the work when the splash resolves.
``keys`` declares the bare-key shortcuts that the splash answers. It resolves with a
:class:`~meshterm.ui.tui.select.KeyRequest` (the hide and show-all pair of the
picker). ``key_hint`` names them for each highlighted row, so the footer advertises
a key only where the key acts. ``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.
Return ``True`` only if the user commits.
``backdrop_items`` (the rows of the picker) floats the confirm over a device list
that is drawn again. ``backdrop_default`` highlights the row that the action is for.
"""
raise NotImplementedError
async def notify_startup(
self,
renderable: RenderableType,
*,
title: str = "",
banner: Any = None,
footnote: str | None = None,
) -> None:
"""Show a message on a startup splash that has no chrome, until the user closes 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 the startup splash shows a spinner. 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 the pairing PIN of a Bluetooth companion 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 startup splash that has no chrome. ``None`` if cancelled."""
raise NotImplementedError
async def reorder(self, title: str, labels: list[str]) -> list[int]:
"""Let the user put the rows in a new order with the arrows.
Returns:
The new order of the 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:
"""Ask for a line of text. Return it, or ``None`` if the user cancels.
Keep ``title`` short, because it is in the border of the dialog. Put the question or
the instruction in ``prompt``, which is drawn above the field. Thus a text dialog
looks like the button dialogs. ``floating`` makes the prompt draw as a centred
dialog also when there is nothing under it (a modal in the middle of a flow, such as
a remote-admin password). Without it, the prompt fills the frame. A backend that has
no stack of screens ignores it.
"""
raise NotImplementedError
async def confirm(self, title: str, *, default: bool = True) -> bool | None:
"""Ask a yes/no question. Return the answer, or ``None`` if the user cancels."""
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 centred button dialog. Return the selected value, or ``None`` on Esc.
This is the general dialog to select one option (a prompt above a row of buttons).
There are two levels of caution, and they colour the prompt and the border.
``danger`` (amber) is for a disruptive choice, such as to discard edits or to
reboot. ``destructive`` (the reserved error red) is for a loss of data that cannot
be undone. Thus a delete confirm is red, the same as its typed-confirm sibling.
The buttons follow the convention of platform dialogs. The safe way out is on the
left, and the committing action is on the right. The committing action is also the
correct ``default``. Thus Enter commits it, and Esc always backs out.
The code draws a :class:`~rich.text.Text` prompt that has a style as it is, so the
prompt can name a node in its own colours. The level then colours only the border,
and the prose of the prompt has the colour of the level itself.
"""
raise NotImplementedError
async def typed_confirm(self, warning: str, word: str, *, title: str = "Are you sure?") -> bool:
"""Make the user type ``word`` before a destructive action. Return if it was typed."""
raise NotImplementedError
async def autocomplete(
self,
title: str,
choices: list[str],
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
) -> str | None:
"""Ask for free text with suggestions. Return it, or ``None`` if the user cancels."""
raise NotImplementedError
async def path(self, title: str, *, prompt: str = "", default: str = "") -> str | None:
"""Ask for a filesystem path. Return it, or ``None`` if the user cancels."""
raise NotImplementedError
class PlainUi(Ui):
"""The CLI backend. It prints directly to the scripted console and has no prompts.
The backend does not support interactive prompts. The console that it prints on emits
no colour, wraps nothing, and trims trailing whitespace
(:func:`meshterm.ui.script.console`). Everything that a tool gives to it passes through
:func:`~meshterm.ui.script.flatten` on the way out. Thus no border, no box, and no
hoisted title reaches stdout. That fold is a safety net and not the design. The output
that a script must read is *written* in the CLI's own vocabulary (refer to
:mod:`meshterm.ui.script`).
"""
def __init__(self, console: Console) -> None:
"""Connect the backend to a Rich console.
Args:
console: The console to which the output of a tool is printed.
"""
self.console = console
def show(self, *renderables: RenderableType) -> None:
"""Print each renderable at once, with no frame.
Refer to :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 at once, as plain text.
The parameter is markup. This is the contract of the shared ``Ui`` class, and more
than forty callers write markup. But the scripted console does not interpret
markup. A node broadcasts its own name, and Rich would read a ``[...]`` in a name as
a style tag. Thus this method resolves the tags and the console does not, and the
text that the tags wrapped is what reaches stdout. When the method printed the tags
as they were, ``[muted]`` and ``[/muted]`` were around the private key. But
``config export-key > key.hex`` must have only the key as its content.
Malformed markup is what a *name* that has a bracket looks like. It is not an error
to report but a string to print, so the method prints it as it is.
"""
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 the acknowledgement on stderr, where all output *about* the run goes.
The CLI once removed these lines. The reason was correct, but only to a point. An
acknowledgement is not the answer, and a caller that redirects stdout must catch
only the answer. But stdout is not the only stream. If the CLI prints them on
stderr, it follows that rule exactly. Also, a person who watches a ``config
restore`` of five minutes gets the ✓ for each setting, which the person could not
see before.
This method resolves the markup and the console does not, as :meth:`note` explains.
A *name* that has a square bracket is not an error to report but a string to print.
Thus malformed markup is printed as it is.
"""
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 at once, with no frame. CLI mode has no screens."""
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 that is drawn on stderr, so it is never in piped output.
A progress bar helps a person who watches a long command run. For a program that
reads the output of the command, it is noise. stderr is the correct place for it.
It is visible in a terminal, and it is not in ``meshterm contacts > contacts.txt``.
Also, Rich draws nothing when stderr is not a terminal, so a run with all streams
redirected 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 display 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.
It still yields an object with a caption that a caller can set. A caller that
changes the title of the card in the middle of a batch must not have to ask which
backend it runs on.
"""
yield _NullBusy()
def _no_prompt(self) -> RuntimeError:
"""Build the error that is raised if a 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:
"""Not supported in scripted CLI mode. The picker splash is only for the menu."""
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:
"""There is no splash in scripted CLI mode. 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:
"""Not supported in scripted CLI mode. The caller must give a PIN with no prompt.
The scripted path cannot show a dialog. Thus a device that has a PIN gets the clear
``DeviceAuthenticationError`` message (the user passes ``--ble-pin``) and not 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:
"""Not supported in scripted CLI mode. A network endpoint comes from ``--tcp``."""
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:
"""Ask on the terminal (line editor or getpass). Ask again until the input is valid.
A few tools (for example, a remote-admin password) can correctly ask a question in a
scripted run when the user did not give a flag. Thus this method works on the CLI.
The method prints the in-body ``prompt`` (which the interactive dialogs use) one time
as a lead-in line. ``floating`` is an extra for a full-screen dialog, and it has no
meaning on the plain terminal. The method accepts it and ignores it.
Returns:
The string that the user entered, or ``None`` on EOF or an 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:
"""Not supported in scripted CLI mode. A destructive CLI command needs ``--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):
"""The menu backend. It buffers output for a result screen and sends prompts to the session."""
def __init__(self, session: TuiSession) -> None:
"""Connect the backend to a running session.
Args:
session: The full-screen session that renders the prompts and the screens.
"""
self.session = session
self._buffer: list[RenderableType] = []
# --- output --------------------------------------------------------------
def show(self, *renderables: RenderableType) -> None:
"""Collect renderables for the next result screen."""
self._buffer.extend(renderables)
def note(self, markup: str) -> None:
"""Collect a markup line for the next result screen."""
self._buffer.append(Text.from_markup(markup))
def ack(self, markup: str) -> None:
"""Collect an acknowledgement. In the menu, it is the same as a note."""
self.note(markup)
async def present(self, *, title: str = "") -> None:
"""Show all output that was buffered since the last present, on a screen or in a dialog.
A short outcome that has only text (a line or two of notes, for example "✓ flood
advertisement sent") floats as a centred OK dialog over the current screen. Thus a
result of one line never takes the whole frame. A bigger outcome, or an outcome that
has a table or a panel, opens the scrollable result screen of limited height, as
before. Refer to :func:`_collapse_to_message` for the exact gate. The method then
clears the buffer. It does nothing if nothing was buffered (for example, a tool that
made only a file and an empty message).
Args:
title: The heading for the result screen or the dialog.
"""
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:
"""Remove all buffered output and do not show it."""
self._buffer = []
async def view(
self, renderable: RenderableType, *, title: str = "", footer_hint: str = ""
) -> None:
"""Show a renderable at once on a scroll screen that the user can close."""
await self.session.scroll(renderable, title=title, footer_hint=footer_hint)
def progress(self, title: str = "Working"): # noqa: ANN201
"""Return a context manager of a progress dialog for the session."""
return self.session.progress(title)
def busy_overlay(self, message: str = "", *, title: str = ""): # noqa: ANN201
"""Float the skeleton card at the top of the session 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 modal busy card of the session 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:
"""Give the work to the select screen of the session."""
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:
"""Give the work to the startup select splash of the session, which has no chrome."""
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:
"""Give the work to the startup confirm dialog of the session (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:
"""Give the work to the startup message splash of the session, which has no chrome."""
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:
"""Give the work to the startup splash of the session that has an animated spinner."""
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:
"""Give the work to the startup PIN dialog of the session."""
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:
"""Give the work to the startup text dialog of the session, which has no chrome."""
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]:
"""Give the work to the reorder screen of the session."""
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:
"""Give the work to the text screen of the session."""
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:
"""Give the work to the confirm screen of the session."""
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:
"""Give the work to the button dialog of the session, at the requested caution level.
``danger`` gives the frame a colour of caution. ``destructive`` uses the reserved
error red, and it is only for a loss of data that cannot be undone.
"""
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 "border",
)
async def typed_confirm(self, warning: str, word: str, *, title: str = "Are you sure?") -> bool:
"""Give the work to the typed-confirmation dialog of the session."""
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:
"""Give the work to the autocomplete screen of the session."""
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:
"""Ask for a path as free text. The current value is already in the field."""
return await self.session.text(
title, prompt=prompt, default=default, help_text="filesystem path"
)