Confirm quitting with a centered Cancel/Quit dialog
Buttons read Cancel then Quit, with Quit highlighted so Enter commits it and Esc cancels. The question is orange, the selected button cyan, and the idle button gray, so the current choice is unmistakable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
bfae10b387
commit
e0487b46e6
5 files changed
+230
-33
No files matched your search
+43
-29
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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``).
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+44
-1
@@ -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"])
|
||||
|
||||
Reference in new issue
Block a user