^Q asks before it quits, and Esc at the main menu stays put
Esc-mashing back to the menu used to land on the quit confirm, and ^Q, one key from ^W, quit with no question at all. Now Esc at the main menu only clears a typed filter, and ^Q floats the same quit confirm the Quit row opens, from anywhere and without stopping what runs underneath; a second ^Q while it asks leaves at once. The main menu's hint names ^Q quit, the only place the chord is shown. Option D from issue #22. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0e9f41fb2a
commit
7071214660
6 files changed
+305
-81
No files matched your search
@@ -148,11 +148,18 @@ deliberately, one at a time, and say why in the code.
|
||||
for as long as it is true. Chat is the same shape with a different peelable thing — its
|
||||
first Esc unpicks a selected message. It used to be a 2–2 split, which put opposite
|
||||
outcomes behind the same keystroke on the same affordance.
|
||||
- **^W** unwinds every frame back to the main menu, **^Q** quits from anywhere. Both are
|
||||
answered in `TuiSession._dispatch` and neither is ever advertised — a global verb has no
|
||||
screen to belong to, and the F-key lane has only three free slots per screen. They are
|
||||
stated once, on the About page. `test_navigation` walks every string literal in the
|
||||
package to keep them out of the UI. ^W arms **every frame on the stack**, not just the
|
||||
- **^W** unwinds every frame back to the main menu, **^Q** asks to quit from anywhere: it
|
||||
floats the same confirm the menu's Quit row opens, detached over whatever is up so
|
||||
nothing under it stops, and a second ^Q while it asks leaves at once
|
||||
(`TuiSession.request_quit`; ^Q sits one key from ^W). Both are answered in
|
||||
`TuiSession._dispatch`, and neither is advertised — a global verb has no screen to
|
||||
belong to, and the F-key lane has only three free slots per screen — except `^Q quit`
|
||||
on the main menu's own hint, where Esc is inert and the reader looks for the way out.
|
||||
`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
|
||||
the stack, so Esc-mashing up to it settles there. Leaving is the Quit row or ^Q, both
|
||||
behind the confirm. ^W arms **every frame on the stack**, not just the
|
||||
top (`request_pop_all`), stopping at anything modal: a screen opened from a key handler
|
||||
— the packet viewer, the chat's delivery paths, a trace path flow — runs in a task no
|
||||
navigation frame awaits, so an unwind sent only to the top died there while the hub
|
||||
@@ -265,7 +272,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
- ≤72 cells. Sentence shape: navigation keys, then action keys, **Esc last**.
|
||||
- Esc verb by surface: `Esc back` leaves a screen · `Esc close` dismisses a read-only
|
||||
floating view · `Esc cancel` abandons a prompt/dialog · `Esc keep` leaves a value
|
||||
picker unchanged · `Esc quit` only at the main menu · `Esc bye` only on the device
|
||||
picker unchanged · no Esc atom at the main menu, which ends on `^Q quit` instead ·
|
||||
`Esc bye` only on the device
|
||||
splash, which is the door rather than a screen — nothing has been started there to
|
||||
quit out of. While a find-as-you-type filter is standing the verb becomes `Esc clear`,
|
||||
because that is what the press does then.
|
||||
|
||||
+88
-49
@@ -39,7 +39,6 @@ from .menus import (
|
||||
from .surface import TuiUi
|
||||
from .theme import make_console
|
||||
from .tui import (
|
||||
CANCEL,
|
||||
Choice,
|
||||
PopToMenu,
|
||||
ReconnectDialog,
|
||||
@@ -398,6 +397,8 @@ async def run_menu(ctx: AppContext) -> None:
|
||||
header_cache: dict = {}
|
||||
session = TuiSession(header=lambda cols: _header(ctx, header_cache, cols))
|
||||
ctx.ui = TuiUi(session)
|
||||
# The one quit confirm, asked by the menu's Quit row and by the quit chord from anywhere.
|
||||
session.set_quit_confirm(lambda: _confirm_quit(ctx, session))
|
||||
|
||||
async def main() -> None:
|
||||
try:
|
||||
@@ -482,11 +483,81 @@ async def _unpair_on_exit(ctx: AppContext) -> None:
|
||||
#: can show, the same as any tool's.
|
||||
_QUIT_ICON = "🚪"
|
||||
|
||||
#: The value the Quit row resolves with — Esc's ``CANCEL`` and this both open the quit
|
||||
#: confirm (see :func:`_menu_round`).
|
||||
#: The value the Quit row resolves with, which opens the quit confirm (see
|
||||
#: :func:`_menu_round`). Esc at the menu does not: it is inert there (see :class:`_MainMenu`).
|
||||
_QUIT_VALUE = "__quit__"
|
||||
|
||||
|
||||
async def _confirm_quit(ctx: AppContext, session: TuiSession) -> bool:
|
||||
"""Float the quit confirm over whatever is up; return whether to leave.
|
||||
|
||||
One dialog for both doors — the menu's Quit row and the quit chord, which asks from
|
||||
anywhere (see :meth:`~meshterm.ui.tui.session.TuiSession.request_quit`). Cancel (Esc)
|
||||
sits left of Quit (Enter), and Quit starts highlighted so Enter commits it.
|
||||
|
||||
It offers to drop the OS pairing on the way out, but only when there is a live Bluetooth
|
||||
bond to drop — never on serial, an open (PIN-less) companion, or a platform we can't
|
||||
unpair. That button sits between Cancel and Quit and is never the default, so it takes a
|
||||
deliberate choice, not a stray Enter. The unpair itself is deferred to teardown (see
|
||||
:func:`run_menu`), because the link has to be disconnected first; the remembered-device
|
||||
record is intentionally kept, so the device just asks for its PIN again.
|
||||
|
||||
Args:
|
||||
ctx: The shared application context (``unpair_on_exit`` is set on *Unpair & quit*).
|
||||
session: The running TUI session.
|
||||
|
||||
Returns:
|
||||
``True`` if the reader chose Quit or Unpair & quit.
|
||||
"""
|
||||
buttons = [("Cancel", "cancel")]
|
||||
if await _can_unpair(ctx):
|
||||
buttons.append(("Unpair & quit", "unpair"))
|
||||
buttons.append(("Quit", "quit"))
|
||||
choice = await session.button_dialog(
|
||||
"Are you sure you want to quit?",
|
||||
buttons,
|
||||
title="Quit MeshTerm",
|
||||
default=len(buttons) - 1, # highlight Quit
|
||||
# The hint stays the dialog default: Enter commits *the highlighted button*,
|
||||
# which is Quit only until ←→ moves it — and with a third button in the row,
|
||||
# naming the key that moves it is the point.
|
||||
prompt_style="warn",
|
||||
button_style="selected",
|
||||
button_idle_style="muted",
|
||||
border_style="warn",
|
||||
)
|
||||
if choice == "unpair":
|
||||
ctx.unpair_on_exit = True
|
||||
return choice in ("unpair", "quit")
|
||||
|
||||
|
||||
class _MainMenu(SelectScreen):
|
||||
"""The main menu's list, on which Esc peels a typed filter and otherwise does nothing.
|
||||
|
||||
Pressing Esc over and over is the natural climb back to the menu, and when Esc at the
|
||||
top opened the quit confirm, the presses that landed after the climb opened it too — the
|
||||
reader asked for the menu and got a question about leaving (issue #22). The menu is the
|
||||
bottom of the stack, so there is nothing for Esc to go back to; leaving is a choice made
|
||||
on the Quit row, or with the quit chord, both of which ask first.
|
||||
|
||||
The footer therefore ends on the quit chord rather than on Esc, until a filter is
|
||||
standing and ``Esc clear`` joins it last, because that is what the press does then —
|
||||
the same atom every filtering list shows.
|
||||
"""
|
||||
|
||||
def handle(self, action: str, data: str = "") -> None:
|
||||
"""Answer every key as a select list does, except a bare Esc, which is inert."""
|
||||
if action == "escape" and not self._filter:
|
||||
return
|
||||
super().handle(action, data)
|
||||
|
||||
@property
|
||||
def footer_hint(self) -> str:
|
||||
"""The list's own hint, gaining ``Esc clear`` only while a filter is standing."""
|
||||
base = super().footer_hint
|
||||
return f"{base} · Esc clear" if self._filter else base
|
||||
|
||||
|
||||
def _menu_lane(tools: Sequence[Any]) -> int:
|
||||
"""The main menu's icon column in cells: every tool's mark *and* the Quit row's.
|
||||
|
||||
@@ -614,18 +685,20 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None:
|
||||
# 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.
|
||||
menu = SelectScreen(
|
||||
menu = _MainMenu(
|
||||
"What would you like to do?",
|
||||
items,
|
||||
footer_hint="↑↓ move · type to filter · Enter select · Esc quit",
|
||||
# The one place the quit chord is named: the menu is where a reader looks for the
|
||||
# way out, and with Esc inert here the footer would otherwise name none. Everywhere
|
||||
# else it stays unadvertised, like ^W (see session._CTRL_LETTER_CHORDS).
|
||||
footer_hint="↑↓ move · type to filter · Enter select · ^Q quit",
|
||||
)
|
||||
session.set_root(menu)
|
||||
loop = asyncio.get_running_loop()
|
||||
while True:
|
||||
try:
|
||||
await _menu_round(ctx, session, menu, tools, loop)
|
||||
except _Quit as chosen:
|
||||
ctx.unpair_on_exit = chosen.unpair
|
||||
except _Quit:
|
||||
return
|
||||
except PopToMenu:
|
||||
# ^W from somewhere deep. Every frame between there and here has already
|
||||
@@ -635,18 +708,14 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None:
|
||||
|
||||
|
||||
class _Quit(Exception):
|
||||
"""The user confirmed the quit dialog; ``unpair`` says whether to drop the OS bond too.
|
||||
"""The user confirmed the quit dialog from the menu's Quit row.
|
||||
|
||||
A menu round runs a whole tool inside itself, so "the user chose Quit" cannot be spelled
|
||||
as a ``return`` — the loop would just show the menu again. It is raised instead, and
|
||||
caught in the one frame that owns the decision to leave.
|
||||
caught in the one frame that owns the decision to leave. Whether to drop the OS bond too
|
||||
is already recorded on the context by the confirm (see :func:`_confirm_quit`).
|
||||
"""
|
||||
|
||||
def __init__(self, *, unpair: bool) -> None:
|
||||
"""Record whether the OS pairing should be dropped on the way out."""
|
||||
super().__init__("quit")
|
||||
self.unpair = unpair
|
||||
|
||||
|
||||
async def _menu_round(
|
||||
ctx: AppContext,
|
||||
@@ -677,41 +746,11 @@ async def _menu_round(
|
||||
ran_over_menu = False
|
||||
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_VALUE):
|
||||
# Offer to drop the OS pairing on the way out, but only when there is a live
|
||||
# Bluetooth bond to drop — never on serial, an open (PIN-less) companion, or a
|
||||
# platform we can't unpair. The extra button sits between Cancel and Quit and is
|
||||
# never the default, so it takes a deliberate choice, not a stray Enter.
|
||||
unpairable = await _can_unpair(ctx)
|
||||
buttons = [("Cancel", "cancel")]
|
||||
if unpairable:
|
||||
buttons.append(("Unpair & quit", "unpair"))
|
||||
buttons.append(("Quit", "quit"))
|
||||
choice = await session.button_dialog(
|
||||
"Are you sure you want to quit?",
|
||||
buttons,
|
||||
title="Quit MeshTerm",
|
||||
default=len(buttons) - 1, # highlight Quit
|
||||
# The hint stays the dialog default: Enter commits *the highlighted
|
||||
# button*, which is Quit only until ←→ moves it — and with a third
|
||||
# button in the row, naming the key that moves it is the point.
|
||||
prompt_style="warn",
|
||||
button_style="selected",
|
||||
button_idle_style="muted",
|
||||
border_style="warn",
|
||||
)
|
||||
# "Unpair & quit" forgets the OS bond as we leave; the disconnect must happen
|
||||
# first, so the actual unpair is deferred to teardown (see run_menu). The
|
||||
# remembered-device record is intentionally kept — the device just asks for its
|
||||
# PIN again. Cancel (button or Esc → None) ends the round and redraws the menu,
|
||||
# whose cursor never moved off the row they came from.
|
||||
if choice in ("unpair", "quit"):
|
||||
raise _Quit(unpair=choice == "unpair")
|
||||
# The Quit row asks first, on a dialog floating over the (still-pushed) menu; Cancel
|
||||
# ends the round and redraws the menu, whose cursor never moved off the row.
|
||||
if selection == _QUIT_VALUE:
|
||||
if await session.confirm_quit():
|
||||
raise _Quit()
|
||||
return
|
||||
# A popup tool runs while the menu is still pushed, so its prompts and
|
||||
# result float over it as modal dialogs instead of replacing the screen.
|
||||
|
||||
@@ -212,7 +212,7 @@ _BASE_BOX_CACHE: tuple[tuple, list[str]] | None = None
|
||||
#: The screen's own way out, lifted off the end of its footer hint. The grammar is
|
||||
#: mandated — "navigation keys, then action keys, **Esc last**" — so the last atom is the
|
||||
#: Esc clause wherever a screen has one, and its verb is the true one for that surface
|
||||
#: (``back`` on a screen, ``quit`` at the main menu, ``keep`` in a value picker).
|
||||
#: (``back`` on a screen, ``keep`` in a value picker; the main menu names none).
|
||||
_ESC_ATOM = re.compile(r"(?:^|·\s*)(Esc\s+\S+)\s*$")
|
||||
|
||||
#: Rule cells the title keeps on each side before the bar gives the Esc hint back. Below
|
||||
@@ -274,10 +274,11 @@ def _title_bar(screen: Screen, cols: int, more_above: bool, more_below: bool) ->
|
||||
its five slots do — so nothing on the screen said that Esc leaves, which is the one key
|
||||
every screen answers to and the reason no screen spends a row on a *Back* item. The
|
||||
verb is the screen's own (:func:`_esc_hint` lifts the atom off its footer hint, so a
|
||||
value picker says ``keep`` and the main menu says ``quit``); a screen whose hint names
|
||||
no Esc gets nothing. Compact by construction: where a long title would be crowded, the
|
||||
atom drops to a bare ``Esc`` and then out altogether — the title is what the reader
|
||||
came for, and the arrows are two cells nothing else can spend.
|
||||
value picker says ``keep``); a screen whose hint names no Esc gets nothing — the
|
||||
main menu, where Esc is inert until a filter is typed. Compact by construction: where
|
||||
a long title would be crowded, the atom drops to a bare ``Esc`` and then out
|
||||
altogether — the title is what the reader came for, and the arrows are two cells
|
||||
nothing else can spend.
|
||||
"""
|
||||
border = "accent"
|
||||
head_span = 3 # the arrow pair, plus the space parting it from the rule
|
||||
|
||||
+85
-19
@@ -68,10 +68,12 @@ from .spinner import spinner_interval
|
||||
#: walk, because what it names on both is our own node.
|
||||
#:
|
||||
#: Two of these are app-wide rather than a screen's: ``to_menu`` (^W) unwinds the whole
|
||||
#: navigation stack back to the main menu, and ``quit`` (^Q, alongside the older ^C) leaves
|
||||
#: the app from anywhere. Neither is advertised in a footer hint or an F-key chip — the lane
|
||||
#: has three free slots per screen and a global verb would claim one on every screen forever,
|
||||
#: so both are stated once on the About page's key list instead (JP, 2026-08-30). ^W was
|
||||
#: navigation stack back to the main menu, and ``quit`` (^Q, alongside the older ^C) asks
|
||||
#: to leave the app from anywhere — a second press while it asks is the leaving (see
|
||||
#: :meth:`TuiSession.request_quit`). Neither is advertised in a footer hint or an F-key
|
||||
#: chip — the lane has three free slots per screen and a global verb would claim one on
|
||||
#: every screen forever (JP, 2026-08-30) — save ``^Q quit`` on the main menu's own hint,
|
||||
#: where Esc is inert and the reader looks for the way out (issue #22). ^W was
|
||||
#: picked for the close-this-whole-thing reflex; both were verified deliverable on the
|
||||
#: Canadian Multilingual layout (^W ``U+0017``, ^Q ``U+0011``) and neither is eaten by the
|
||||
#: terminal — prompt_toolkit's raw mode clears ``IXON``/``IXOFF``, so ^Q is not XON here.
|
||||
@@ -515,6 +517,11 @@ class TuiSession:
|
||||
# the top rather than pointlessly rebuilding it — and what a popup floats over when
|
||||
# nothing else is pushed (see :meth:`_floated`). ``None`` until the menu declares it.
|
||||
self._root: Screen | None = None
|
||||
# The quit confirm ^Q raises from anywhere (see :meth:`request_quit`), declared by
|
||||
# the app with :meth:`set_quit_confirm`; and whether it is up right now, which is
|
||||
# what turns the next ^Q into the leaving itself.
|
||||
self._quit_confirm: Callable[[], Awaitable[bool]] | None = None
|
||||
self._quit_asking = False
|
||||
|
||||
# --- stack ---------------------------------------------------------------
|
||||
|
||||
@@ -596,6 +603,74 @@ class TuiSession:
|
||||
"""The declared navigation root (the main menu), or ``None`` before it is declared."""
|
||||
return self._root
|
||||
|
||||
def set_quit_confirm(self, ask: Callable[[], Awaitable[bool]] | None) -> None:
|
||||
"""Declare the question the quit chord asks before it leaves.
|
||||
|
||||
The app declares it once, because only the app knows what the question offers —
|
||||
the main menu's confirm adds *Unpair & quit* over a Bluetooth bond, which needs the
|
||||
application context the session never holds. ``ask`` floats its dialog and returns
|
||||
whether to leave; it owns any side effect of the answer (recording an unpair).
|
||||
|
||||
Args:
|
||||
ask: The confirm, or ``None`` to have the quit chord leave without asking.
|
||||
"""
|
||||
self._quit_confirm = ask
|
||||
|
||||
async def confirm_quit(self) -> bool:
|
||||
"""Ask the declared quit confirm, as the main menu's Quit row does.
|
||||
|
||||
The same question ^Q asks, and the same dialog: while it is up the chord's next
|
||||
press leaves outright (see :meth:`request_quit`), whichever door opened it.
|
||||
|
||||
Returns:
|
||||
Whether the reader chose to leave — ``True`` with no confirm declared.
|
||||
"""
|
||||
self._quit_asking = True
|
||||
return await self._asking_quit()
|
||||
|
||||
async def _asking_quit(self) -> bool:
|
||||
"""Run the declared confirm with :attr:`_quit_asking` already raised, then lower it."""
|
||||
try:
|
||||
return self._quit_confirm is None or bool(await self._quit_confirm())
|
||||
finally:
|
||||
self._quit_asking = False
|
||||
|
||||
def request_quit(self) -> None:
|
||||
"""Answer the quit chord (^Q, and ^C beside it): ask first, leave on the second press.
|
||||
|
||||
^Q sits one key from ^W, the chord that climbs back to the menu, so a slip on the way
|
||||
to the menu used to close the app — and a running capture or a courier queue with
|
||||
it. It now floats the quit confirm over whatever is up, from anywhere (issue #22,
|
||||
option D). The confirm is a detached flow (:meth:`run_detached`): nothing under it
|
||||
is interrupted, a capture keeps capturing behind the box, and Esc puts the reader
|
||||
back exactly where they were.
|
||||
|
||||
While the confirm is up, the chord leaves at once, so the fastest way out is still
|
||||
two keys and still works when a screen is wedged or a device read hangs — the
|
||||
second press never waits on the dialog to paint or take a key. The flag is raised
|
||||
here, synchronously, so two presses arriving in one input batch are still a quit
|
||||
rather than two dialogs.
|
||||
|
||||
Leaving is clean either way: :meth:`run` cancels the coroutine driving the app and
|
||||
waits for its unwind, so the history and chat runs close, the services stop, and the
|
||||
exit watchdog is armed, the same as quitting from the menu.
|
||||
"""
|
||||
if self._quit_asking or self._quit_confirm is None:
|
||||
self._exit_app()
|
||||
return
|
||||
self._quit_asking = True
|
||||
|
||||
async def ask() -> None:
|
||||
if await self._asking_quit():
|
||||
self._exit_app()
|
||||
|
||||
self.run_detached(ask())
|
||||
|
||||
def _exit_app(self) -> None:
|
||||
"""Exit the running application, from under whatever flow is driving it."""
|
||||
if self._app is not None and self._app.is_running:
|
||||
self._app.exit()
|
||||
|
||||
def request_pop_all(self) -> bool:
|
||||
"""Arm the unwind to the navigation root (the ^W key). Returns whether it fired.
|
||||
|
||||
@@ -1603,7 +1678,8 @@ class TuiSession:
|
||||
# touches the screen. Errors from ``main`` still propagate via ``driver``/``box``.
|
||||
await self._app.run_async(pre_run=pre_run, set_exception_handler=False)
|
||||
# The app can also exit from *under* the driver: the quit chords (^Q/^C) call
|
||||
# ``Application.exit`` straight from the key handler, so ``run_async`` returns while
|
||||
# ``Application.exit`` from their detached confirm, or straight from the key handler
|
||||
# on the second press (see request_quit), so ``run_async`` returns while
|
||||
# ``main`` is still parked on whatever screen was up. Left alone, that coroutine is
|
||||
# simply abandoned mid-await and its ``finally`` never runs — which is where the
|
||||
# history and chat runs are closed, the background services stopped, and the exit
|
||||
@@ -2030,20 +2106,10 @@ class TuiSession:
|
||||
self.invalidate()
|
||||
return
|
||||
if action == "quit":
|
||||
# Straight out (^Q/^C), with no confirmation — deliberately, and not an
|
||||
# oversight to be tidied up later. The quit dialog exists to catch *one Esc too
|
||||
# many* at the main menu, where a single reflexive keystroke on the way out of
|
||||
# something would otherwise end the session (JP, 2026-08-30). Neither of these
|
||||
# chords can be pressed by accident, so there is nothing for a confirm to catch
|
||||
# — and both are the escape hatch: a screen that is wedged, or a device read
|
||||
# that is hanging, is exactly when a confirm dialog (itself a screen, which has
|
||||
# to paint and take a key) would be the thing in the way. Leaving is still
|
||||
# clean: ``run`` cancels the coroutine driving the app and waits for its unwind,
|
||||
# so the history and chat runs close, the services stop, and the exit watchdog
|
||||
# is armed, the same as quitting from the menu. What this route does *not* offer
|
||||
# is the dialog's "Unpair & quit" — that stays a deliberate, menu-only choice.
|
||||
if self._app is not None:
|
||||
self._app.exit()
|
||||
# Asks first, from anywhere; a second press while it asks leaves (see
|
||||
# request_quit). "Unpair & quit" rides the confirm the menu declared.
|
||||
self.request_quit()
|
||||
self.invalidate()
|
||||
return
|
||||
if action == "paste_clipboard":
|
||||
# Some terminals deliver Ctrl-V as the literal control key — no bracketed-paste
|
||||
|
||||
+111
-2
@@ -866,6 +866,110 @@ async def test_quit_from_a_nested_screen_still_runs_the_apps_teardown() -> None:
|
||||
assert torn_down == ["services closed"]
|
||||
|
||||
|
||||
def _asking_session(inp, asked: list[str], answer: str | None = None):
|
||||
"""A session whose quit chord floats a Cancel/Quit confirm, the way the menu declares it.
|
||||
|
||||
``answer`` resolves the dialog as soon as it is pushed (``None`` leaves it up for the
|
||||
test to drive); ``asked`` records each time the question is put.
|
||||
"""
|
||||
session = _session(inp)
|
||||
|
||||
async def ask() -> bool:
|
||||
asked.append("asked")
|
||||
dialog = ButtonDialog("Quit?", [("Cancel", "cancel"), ("Quit", "quit")], default=1)
|
||||
if answer is not None:
|
||||
asyncio.get_running_loop().call_soon(dialog.resolve, answer)
|
||||
return await session.run_dialog(dialog) == "quit"
|
||||
|
||||
session.set_quit_confirm(ask)
|
||||
return session
|
||||
|
||||
|
||||
async def test_quit_asks_first_and_cancelling_leaves_the_flow_where_it_was() -> None:
|
||||
"""One ^Q floats the confirm over whatever is up; Cancel puts the reader back.
|
||||
|
||||
The screen under it is never resolved or popped — a capture behind the box keeps
|
||||
capturing — which is the whole difference from the old straight-out chord (issue #22).
|
||||
"""
|
||||
with create_pipe_input() as inp:
|
||||
asked: list[str] = []
|
||||
session = _asking_session(inp, asked, answer="cancel")
|
||||
screen = ScrollScreen("a capture in progress")
|
||||
|
||||
async def main() -> None:
|
||||
session.push(screen)
|
||||
session._dispatch("quit")
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
assert asked == ["asked"]
|
||||
assert session.top is screen, "Cancel lands back on the screen it floated over"
|
||||
assert not session._quit_asking
|
||||
session.pop(screen)
|
||||
|
||||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||||
assert screen.future is None or not screen.future.done()
|
||||
|
||||
|
||||
async def test_a_second_quit_while_the_confirm_is_up_leaves_at_once() -> None:
|
||||
"""^Q ^Q is the fast way out, and it never waits on the dialog to take a key."""
|
||||
with create_pipe_input() as inp:
|
||||
asked: list[str] = []
|
||||
session = _asking_session(inp, asked)
|
||||
torn_down: list[str] = []
|
||||
|
||||
async def main() -> None:
|
||||
try:
|
||||
await session.run_screen(ScrollScreen("deep"))
|
||||
finally:
|
||||
torn_down.append("services closed")
|
||||
|
||||
# Both presses in one batch: the flag is raised synchronously, so the second is
|
||||
# the leaving rather than a second dialog.
|
||||
inp.send_text(QUIT_KEY + QUIT_KEY)
|
||||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||||
assert torn_down == ["services closed"]
|
||||
assert len(asked) <= 1
|
||||
|
||||
|
||||
async def test_quit_confirmed_from_anywhere_exits_the_app() -> None:
|
||||
"""Choosing Quit in the floated confirm exits, and the flow's teardown still runs."""
|
||||
with create_pipe_input() as inp:
|
||||
asked: list[str] = []
|
||||
session = _asking_session(inp, asked, answer="quit")
|
||||
torn_down: list[str] = []
|
||||
|
||||
async def main() -> None:
|
||||
try:
|
||||
await session.run_screen(ScrollScreen("deep"))
|
||||
finally:
|
||||
torn_down.append("services closed")
|
||||
|
||||
inp.send_text(QUIT_KEY)
|
||||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||||
assert asked == ["asked"]
|
||||
assert torn_down == ["services closed"]
|
||||
|
||||
|
||||
def test_esc_at_the_main_menu_does_nothing_but_peel_a_filter() -> None:
|
||||
"""Esc-mashing up to the menu settles there instead of opening the quit confirm."""
|
||||
from meshterm.ui.menu import _MainMenu
|
||||
|
||||
menu = _MainMenu(
|
||||
"menu",
|
||||
[Choice("alpha", 1), Choice("beta", 2)],
|
||||
footer_hint="↑↓ move · type to filter · Enter select · ^Q quit",
|
||||
)
|
||||
menu.future = asyncio.new_event_loop().create_future()
|
||||
menu.handle("escape")
|
||||
assert not menu.future.done(), "a bare Esc at the menu resolves nothing"
|
||||
assert not menu.footer_hint.endswith("Esc clear")
|
||||
menu.handle("text", "b")
|
||||
assert menu.footer_hint.endswith("^Q quit · Esc clear")
|
||||
menu.handle("escape")
|
||||
assert menu._filter == "" and not menu.future.done()
|
||||
assert menu.footer_hint.endswith("^Q quit")
|
||||
|
||||
|
||||
# --- the bindings themselves -------------------------------------------------
|
||||
|
||||
|
||||
@@ -885,10 +989,12 @@ def test_both_global_chords_are_bound_on_both_ctrl_keys() -> None:
|
||||
|
||||
|
||||
def test_neither_global_chord_is_advertised_anywhere() -> None:
|
||||
"""Both are deliberately undiscoverable in the UI — the About page states them instead.
|
||||
"""Both are deliberately undiscoverable in the UI, bar the menu's own way out.
|
||||
|
||||
A global verb has no screen to belong to, so a footer atom or an F-key chip would have to
|
||||
ride on *every* screen; the lane only has three free slots per screen to begin with.
|
||||
ride on *every* screen; the lane only has three free slots per screen to begin with. The
|
||||
one exception is the main menu's hint, which names ^Q because Esc is inert there and the
|
||||
menu is where a reader looks for the way out (issue #22) — exactly one string, there.
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
@@ -919,6 +1025,9 @@ def test_neither_global_chord_is_advertised_anywhere() -> None:
|
||||
and chord.search(node.value)
|
||||
):
|
||||
offenders.append(f"{path.name}:{node.lineno}: {node.value.strip()[:70]}")
|
||||
menu_hint = [o for o in offenders if o.startswith("menu.py:") and o.endswith("· ^Q quit")]
|
||||
assert len(menu_hint) == 1, "the main menu's hint must name the quit chord, once"
|
||||
offenders.remove(menu_hint[0])
|
||||
assert not offenders, "the global chords must not appear in any hint or chip:\n" + "\n".join(
|
||||
offenders
|
||||
)
|
||||
|
||||
@@ -2327,6 +2327,7 @@ def test_right_ctrl_rescue_covers_the_sessions_own_chords(monkeypatch) -> None:
|
||||
class FakeApp:
|
||||
def __init__(self) -> None:
|
||||
self.exited = False
|
||||
self.is_running = True
|
||||
|
||||
def exit(self) -> None:
|
||||
self.exited = True
|
||||
|
||||
Reference in new issue
Block a user