diff --git a/CLAUDE.md b/CLAUDE.md index 147c6fb..8ed2766 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,7 +157,9 @@ deliberately, one at a time, and say why in the code. on the main menu's own hint, where Esc is inert and the reader looks for the way out. On picocalc that atom rides the title bar's tail (`frame._way_out`), and the menu's lane puts the way out on F3: `Quit?` asks, its Shift half F8 `Quit!` leaves without asking - (`quit_now`, the one door that skips the confirm, and only on the menu). + (`quit_now`, the one door that skips the confirm, and only on the menu). The confirm + keeps the chip that asked: its F3 reads `Quit!` and sends the same `quit`, which with + the question up is the leaving — one key pressed twice, as ^Q is. `test_navigation` walks every string literal in the package to keep them out of the rest of the UI. - **Esc at the main menu does nothing** but peel a typed filter: the menu is the bottom of @@ -660,7 +662,8 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT rides the Shift half of the very pager heading for it** (F10 Top behind F5 Page ↑, F9 Bottom behind F4 Page ↓), because Home and End *are* on this keyboard and only ever wanted a chip for consistency. That leaves **F1–F3 free on every screen** for its own - verbs, and `EMPTY_LANE` for a screen with none at all (every dialog). **A chip names an + verbs, and `EMPTY_LANE` for a screen with none at all (every dialog but the quit + confirm, whose F3 is the chip that asked, pressed again). **A chip names an action, never a key** — `Page ↑`, not `PgUp`; `Latest` on a transcript; `Region` on the map, whose Home reframes and whose paging zooms. **A directional pair rises toward its outer key**: where two adjacent chips are opposite ends of one axis, the *up · in · diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index c45c4b3..58a0e65 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -48,7 +48,7 @@ from .tui import ( TuiSession, ) from .tui.emoji_width import install as install_emoji_widths -from .tui.fkeys import FPair +from .tui.fkeys import EMPTY_LANE, FPair from .tui.spinner import spinner_interval from .widgets import battery_cell @@ -514,6 +514,12 @@ async def _confirm_quit(ctx: AppContext, session: TuiSession) -> bool: if await _can_unpair(ctx): buttons.append(("Unpair & quit", "unpair")) buttons.append(("Quit", "quit")) + # On a handheld the question keeps the chip that asked it: F3, where the menu's + # ``Quit?`` sat, reads ``Quit!`` here and sends the same ``quit`` — which, while this + # confirm is up, leaves at once (see TuiSession.request_quit), as a second ^Q does. + # So the way out from the menu is one key pressed twice (JP, 2026-10-02). + lane = list(EMPTY_LANE) + lane[2] = FPair("Quit!", "quit") choice = await session.button_dialog( "Are you sure you want to quit?", buttons, @@ -526,6 +532,7 @@ async def _confirm_quit(ctx: AppContext, session: TuiSession) -> bool: button_style="selected", button_idle_style="muted", border_style="warn", + lane=lane, ) if choice == "unpair": ctx.unpair_on_exit = True diff --git a/meshterm/ui/tui/prompt.py b/meshterm/ui/tui/prompt.py index 304c1b3..1407a2e 100644 --- a/meshterm/ui/tui/prompt.py +++ b/meshterm/ui/tui/prompt.py @@ -8,7 +8,8 @@ prompt shares the framework's look, layering, resize, and Esc-to-cancel behavior from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING from rich.cells import cell_len from rich.console import Group, RenderableType @@ -19,6 +20,9 @@ from .render import render_lines, right_aligned_tail from .screen import Screen from .spinner import Spinner +if TYPE_CHECKING: + from .fkeys import FPair + #: A validator returns ``True`` when the input is acceptable, or an error message to show. Validator = Callable[[str], "bool | str"] @@ -506,6 +510,7 @@ class ButtonDialog(_KeylessDialog): button_style: str = "selected", button_idle_style: str = "muted", border_style: str = "accent", + lane: Sequence[FPair | None] | None = None, ) -> None: """Build a button dialog. @@ -525,8 +530,12 @@ class ButtonDialog(_KeylessDialog): 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). + lane: An F-key lane for the one question that has a chip worth offering — the + quit confirm's, whose F3 is the chip that asked it, pressed again (see + ``menu._confirm_quit``). ``None`` keeps a prompt's empty lane. """ super().__init__() + self._lane = tuple(lane) if lane is not None else None self.title = title self.footer_hint = footer_hint self.border_style = border_style @@ -550,6 +559,14 @@ class ButtonDialog(_KeylessDialog): return list(self._prompt.split("\n")) or [Text("")] return [Text(self._prompt, style=self._prompt_style)] + @property + def picocalc_lane(self): + """The caller's lane where one was given, else a prompt's empty one. + + The per-deck definition, as every screen's is; the Cardputer's deck follows it. + """ + return self._lane if self._lane is not None else super().picocalc_lane + @property def _button_row_width(self) -> int: """Display width of the button row (`` Label `` cells joined by 4-space gaps).""" diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 4e3c32a..a726955 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -1360,6 +1360,7 @@ class TuiSession: button_style: str = "selected", button_idle_style: str = "muted", border_style: str = "accent", + lane: Sequence[fkeys.FPair | None] | None = None, ) -> Any: """Show a centered button dialog; return the chosen value or ``None`` if cancelled. @@ -1380,6 +1381,7 @@ class TuiSession: button_style=button_style, button_idle_style=button_idle_style, border_style=border_style, + lane=lane, ) result = await self.run_dialog(screen) return None if result is CANCEL else result diff --git a/tests/test_menu_quit_row.py b/tests/test_menu_quit_row.py index 6b17ed5..de58cab 100644 --- a/tests/test_menu_quit_row.py +++ b/tests/test_menu_quit_row.py @@ -123,3 +123,27 @@ def test_no_icon_lane_leaves_the_bare_word_flush_with_the_titles(tools: list) -> assert items[-1].value == menu._QUIT_VALUE assert items[-1].title.plain == "Quit" assert set(starts.values()) == {0} + + +def test_the_quit_confirm_keeps_the_chip_that_asked(monkeypatch: pytest.MonkeyPatch) -> None: + """The confirm's F3 reads ``Quit!`` and sends ``quit`` — the menu's own F3, again.""" + import asyncio + + from meshterm.ui.tui import fkeys + + asked: dict = {} + + class Session: + async def button_dialog(self, prompt, buttons, **kwargs): # noqa: ANN001, ANN202 + asked.update(kwargs) + return "cancel" + + async def no_bond(ctx) -> bool: # noqa: ANN001 + return False + + monkeypatch.setattr(menu, "_can_unpair", no_bond) + assert asyncio.run(menu._confirm_quit(SimpleNamespace(), Session())) is False + assert asked["lane"][2].label == "Quit!" + assert fkeys.PICOCALC_DECK.action_for(asked["lane"], 3) == "quit" # F3 + assert fkeys.CARDPUTER_DECK.action_for(asked["lane"], 6) == "quit" # Fn+6, its middle chip + assert [slot for i, slot in enumerate(asked["lane"]) if i != 2] == [None] * 4 diff --git a/tests/test_navigation.py b/tests/test_navigation.py index 129fba5..b470b02 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -1242,3 +1242,41 @@ async def test_the_busy_dialog_is_one_card_however_deeply_it_nests() -> None: await asyncio.wait_for(session.run(main()), timeout=5) assert depths == [2, 2, 2, 1] # pushed once, popped once, hub still underneath + + +async def test_the_chip_that_asked_to_quit_leaves_when_pressed_again() -> None: + """On a handheld, F3 asks from the menu and the confirm keeps F3: a second press leaves. + + The confirm's lane carries ``Quit!`` on the slot the menu's ``Quit?`` sat on, sending + the same ``quit`` — which, with the question up, is the leaving, as a second ^Q is. + """ + from meshterm.platforms import PICOCALC, set_platform + from meshterm.ui.tui.fkeys import EMPTY_LANE, FPair + + set_platform(PICOCALC) + lane = list(EMPTY_LANE) + lane[2] = FPair("Quit!", "quit") + with create_pipe_input() as inp: + session = _session(inp) + + async def ask() -> bool: + dialog = ButtonDialog("Quit?", [("Cancel", "cancel"), ("Quit", "quit")], lane=lane) + return await session.run_dialog(dialog) == "quit" + + session.set_quit_confirm(ask) + torn_down: list[str] = [] + + async def main() -> None: + session.push(ScrollScreen("the menu")) + try: + session._dispatch("quit") # the menu's Quit? chip + for _ in range(5): + await asyncio.sleep(0) + assert isinstance(session.top, ButtonDialog), "the confirm is up" + session._dispatch("f3") # the same chip, on the confirm's lane + await asyncio.sleep(5) # the leaving cancels this + finally: + torn_down.append("services closed") + + await asyncio.wait_for(session.run(main()), timeout=5) + assert torn_down == ["services closed"]