diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index 3496405..a1a2b6e 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -9,6 +9,7 @@ prompts layer as dialogs, and its output appears in a bounded, scrollable result from __future__ import annotations +import asyncio import logging import os import sys @@ -26,7 +27,7 @@ from ..persistence.logging import get_logger from ..tools import all_tools from .surface import TuiUi from .theme import make_console -from .tui import Choice, Separator, TuiSession +from .tui import CANCEL, Choice, SelectScreen, Separator, TuiSession from .tui.emoji_width import calibrate as calibrate_emoji_width @@ -170,6 +171,7 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None: session: The running TUI session. """ last_selection: str | None = None + loop = asyncio.get_running_loop() while True: items: list = [] current_category: str | None = None @@ -181,35 +183,47 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None: items.append(Separator(" ")) items.append(Choice(title="quit", value="__quit__")) - # Re-highlight the tool the user just backed out of, so returning to the menu - # lands the cursor where they left rather than at the top. - selection = await session.select( - "What would you like to do?", items, default=last_selection + # Drive the menu list ourselves (rather than via session.select) so it stays on the + # stack while the quit dialog floats over it: the confirm is drawn as a centered box + # on top of the still-visible menu, not as a screen that replaces it. Re-highlight the + # tool the user just backed out of so returning lands the cursor where they left. + menu = SelectScreen( + "What would you like to do?", + items, + default=last_selection, + footer_hint="↑↓ move · type to filter · Enter select · Esc quit", ) - # Both picking "quit" and pressing Esc at the top level ask to leave; confirm on a - # small dialog first so a stray key doesn't drop the user out of the session. Enter - # takes the highlighted default (Quit) and leaves; Esc — or choosing "Stay" — returns - # to the menu (select maps a cancel to None). - if selection in (None, "__quit__"): - leave = await session.select( - "Leave MeshTerm?", - [ - Choice(title="Quit and disconnect", value="quit"), - Choice(title="Stay", value="stay"), - ], - default="quit", - wrap=False, - filterable=False, - footer_hint="↑↓ move · Enter quit · Esc stay", - ) - if leave == "quit": - return - # Keep the cursor on "quit" when that is what they chose, so a follow-up - # attempt lands where they expect; a stray Esc leaves it where it was. - if selection == "__quit__": - last_selection = "__quit__" - continue - last_selection = selection + menu.future = loop.create_future() + session.push(menu) + try: + selection = await menu.future + if selection is CANCEL: # Esc at the top level + selection = None + # Both picking "quit" and pressing Esc ask to leave; confirm on a dialog floating + # over the (still-pushed) menu so a stray key doesn't drop the user out. Cancel + # (Esc) sits left of Quit (Enter); Quit starts highlighted so Enter commits it. + if selection in (None, "__quit__"): + leave = await session.button_dialog( + "Are you sure you want to quit?", + [("Cancel", False), ("Quit", True)], + title="Quit MeshTerm", + default=1, + footer_hint="Esc cancel · Enter quit", + prompt_style="warn", + button_style="brand", + button_idle_style="muted", + border_style="warn", + ) + if leave: + return + # Keep the cursor on "quit" when that is what they chose, so a follow-up + # attempt lands where they expect; a stray Esc leaves it where it was. + if selection == "__quit__": + last_selection = "__quit__" + continue + last_selection = selection + finally: + session.pop(menu) await _run_selection(ctx, selection) diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index 4ca7edb..dc05a7f 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -246,7 +246,11 @@ def compose_dialog(screen: Screen, cols: int, rows: int) -> str: Returns: An ANSI string sized to the dialog's content (never larger than the terminal). """ - max_w = min(cols - 6, 100) + # Most dialogs stretch to a generous cap; a screen may instead request a natural width + # (a short confirm sized to its content), still bounded to the terminal. + cap = min(cols - 6, 100) + natural = getattr(screen, "dialog_width", None) + max_w = cap if natural is None else max(24, min(cap, natural)) max_h = max(3, rows - 6) body_lines = screen.render_body(max_w - 4) viewport = min(max_h, max(1, len(body_lines))) @@ -260,7 +264,7 @@ def compose_dialog(screen: Screen, cols: int, rows: int) -> str: body, title=f"[accent]{screen.title}[/accent]" if screen.title else None, subtitle=subtitle, - border_style="accent", + border_style=getattr(screen, "border_style", "accent"), padding=(0, 1), width=max_w, ) diff --git a/meshterm/ui/tui/prompt.py b/meshterm/ui/tui/prompt.py index 8f26d8d..b31f0e0 100644 --- a/meshterm/ui/tui/prompt.py +++ b/meshterm/ui/tui/prompt.py @@ -9,6 +9,7 @@ from __future__ import annotations from typing import Callable, Optional +from rich.cells import cell_len from rich.console import Group, RenderableType from rich.text import Text @@ -216,6 +217,105 @@ class ConfirmScreen(Screen): super().handle("escape") +class ButtonDialog(Screen): + """A centered dialog: a prompt above a row of side-by-side buttons. + + A reusable choose-one prompt. The buttons sit in a row; ←/→ (or Tab) move the highlight, + Enter commits the highlighted button, and Esc cancels. Optional single-key shortcuts + commit a button instantly (e.g. ``y``/``n``, self-hinted by ``Yes``/``No`` labels). The + highlight and border colours are parametrised so a caller can theme it (a destructive + action in red, say). Resolves with the chosen button's value, or CANCEL on Esc. + """ + + def __init__( + self, + prompt: str, + buttons: list[tuple[str, object]], + *, + title: str = "", + default: int = 0, + keys: Optional[dict[str, object]] = None, + footer_hint: str = "←→ choose · Enter select · Esc cancel", + prompt_style: str = "", + button_style: str = "reverse brand", + button_idle_style: str = "muted", + border_style: str = "accent", + ) -> None: + """Build a button dialog. + + Args: + prompt: The question shown above the buttons. + buttons: ``(label, value)`` pairs laid out left to right. + title: Optional dialog heading. + default: Index of the initially highlighted button. + keys: Optional map of a lowercase shortcut character to the value it commits + immediately (bypassing the highlight), e.g. ``{"y": True, "n": False}``. + footer_hint: Footer key hint (hints Enter and Esc, as the other dialogs do). + prompt_style: Rich style for the question (e.g. ``"warn"`` for a cautionary + action); empty for the default foreground. + button_style: Rich style for the highlighted button. + button_idle_style: Rich style for the un-highlighted buttons. + border_style: Rich style for the dialog border (read by the frame compositor). + """ + super().__init__() + self.title = title + self.footer_hint = footer_hint + self.border_style = border_style + self._prompt = prompt + self._buttons = buttons + self._index = default if 0 <= default < len(buttons) else 0 + self._keys = {k.lower(): v for k, v in (keys or {}).items()} + self._prompt_style = prompt_style + self._button_style = button_style + self._button_idle_style = button_idle_style + + @property + def _button_row_width(self) -> int: + """Display width of the button row (`` Label `` cells joined by 4-space gaps).""" + cells = sum(cell_len(label) + 4 for label, _ in self._buttons) + gaps = 4 * max(0, len(self._buttons) - 1) + return cells + gaps + + @property + def dialog_width(self) -> int: + """Natural outer width so the frame sizes the box to its content, not the terminal. + + The widest of the prompt, button row, title, and footer hint, plus a comfortable + margin and the border — so a short confirm reads as a tidy box rather than a banner + stretched across the screen. The compositor still caps this to the terminal. + """ + inner = max( + cell_len(self._prompt), + self._button_row_width, + cell_len(self.title), + cell_len(self.footer_hint), + ) + return inner + 12 # panel padding + border, plus horizontal breathing room + + def render_body(self, width: int) -> list[str]: + """Render the prompt centered above a centered row of buttons.""" + row = Text(justify="center") + for i, (label, _value) in enumerate(self._buttons): + if i: + row.append(" ") + style = self._button_style if i == self._index else self._button_idle_style + row.append(f" {label} ", style=style) + prompt = Text(self._prompt, style=self._prompt_style, justify="center") + return render_lines(Group(prompt, Text(""), row), width) + + def handle(self, action: str, data: str = "") -> None: + """Move the highlight, commit on Enter or a shortcut key, or cancel on Esc.""" + if action in ("left", "right", "tab") and self._buttons: + step = -1 if action == "left" else 1 + self._index = (self._index + step) % len(self._buttons) + elif action == "text" and data.lower() in self._keys: + self.resolve(self._keys[data.lower()]) + elif action == "enter" and self._buttons: + self.resolve(self._buttons[self._index][1]) + elif action == "escape": + super().handle("escape") + + class AutocompleteScreen(Screen): """A free-text field with a live suggestion list (``questionary.autocomplete``). diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 3a5d27b..f90d20a 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -26,7 +26,7 @@ from rich.text import Text from . import frame from .progress import TuiProgress -from .prompt import AutocompleteScreen, ConfirmScreen, TextScreen, Validator +from .prompt import AutocompleteScreen, ButtonDialog, ConfirmScreen, TextScreen, Validator from .screen import CANCEL, BusyScreen, Screen, ScrollScreen from .select import Choice, ReorderScreen, SelectScreen, Separator @@ -329,6 +329,42 @@ class TuiSession: result = await self.run_screen(ConfirmScreen(title, default=default)) return None if result is CANCEL else result + async def button_dialog( + self, + prompt: str, + 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 = "reverse brand", + 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. + """ + 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_screen(screen) + return None if result is CANCEL else result + async def autocomplete( self, title: str, diff --git a/tests/test_tui.py b/tests/test_tui.py index 5352a80..1c5ed60 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -17,7 +17,12 @@ from rich.text import Text from meshterm.ui.tui import frame from meshterm.ui.tui.progress import ProgressScreen -from meshterm.ui.tui.prompt import AutocompleteScreen, ConfirmScreen, TextScreen +from meshterm.ui.tui.prompt import ( + AutocompleteScreen, + ButtonDialog, + ConfirmScreen, + TextScreen, +) from meshterm.ui.tui.render import render_lines, render_to_ansi from meshterm.ui.tui.screen import CANCEL, Screen, ScrollScreen from meshterm.ui.tui.select import Choice, SelectScreen, Separator @@ -426,6 +431,44 @@ def test_confirm_toggle_and_default() -> None: assert _run(screen, "enter") is False +def test_button_dialog_enter_commits_highlighted() -> None: + """Enter returns the highlighted button's value; the default sets the highlight.""" + screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=0) + assert _run(screen, "enter") is True + screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=1) + assert _run(screen, "enter") is False + + +def test_button_dialog_arrows_move_highlight() -> None: + """←/→ (and Tab) move the highlight between the buttons, wrapping at the ends.""" + screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=0) + screen.handle("right") # → No + assert _run(screen, "enter") is False + screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=0) + screen.handle("left") # wraps → No + assert _run(screen, "enter") is False + + +def test_button_dialog_shortcut_keys_commit_instantly() -> None: + """A mapped shortcut key commits its value straight away, bypassing the highlight.""" + screen = ButtonDialog( + "quit?", [("Yes", True), ("No", False)], default=1, keys={"y": True, "n": False} + ) + assert _run(screen, "text", "Y") is True # case-insensitive, ignores the No default + screen = ButtonDialog( + "quit?", [("Yes", True), ("No", False)], default=0, keys={"y": True, "n": False} + ) + assert _run(screen, "text", "n") is False + + +def test_button_dialog_escape_cancels() -> None: + """Esc resolves with CANCEL so the caller can treat it as 'stay'.""" + from meshterm.ui.tui.screen import CANCEL + + screen = ButtonDialog("quit?", [("Yes", True), ("No", False)]) + assert _run(screen, "escape") is CANCEL + + def test_autocomplete_suggests_and_tab_completes() -> None: """Suggestions match case-insensitively; Tab fills the highlighted one; Enter commits.""" screen = AutocompleteScreen("target?", ["Alice", "Bob", "alfred"])