diff --git a/docs/survey-navigation.md b/docs/survey-navigation.md index 87376d6..c47be86 100644 --- a/docs/survey-navigation.md +++ b/docs/survey-navigation.md @@ -80,8 +80,12 @@ gone — it is not a place to walk back to, and two of them stacked read as two two picks are now one floating list that turns its page (`menus.run_wizard`, `… · step 1 of 2`): Esc on the second step turns back to the first with the picked node still highlighted, Esc on the first leaves, and the box is popped before the sweep opens — so Esc -from the sweep lands on the main menu, the sweep being the whole visit. Trace's picker is a -full-screen contact list (a place), and stays as it is. +from the sweep lands on the main menu, the sweep being the whole visit. It also *draws* as +a popup: the menu is popped while a tool runs, and a floating screen that is the only frame +on the stack used to be painted as the full-frame background (`_base_index`), so the first +of the two pickers never looked like a question at all. `stay(dialog=True)` now puts the +blank base under a popup's visit that `_run_dialog_screen` puts under a one-shot. Trace's +picker is a full-screen contact list (a place), and stays as it is. **I. An entry chain of prompts was not a stack.** Five flows asked two or more things in a row, and Esc on any of them abandoned the whole flow rather than stepping back one — a diff --git a/meshterm/ui/menus.py b/meshterm/ui/menus.py index acb9307..2baea49 100644 --- a/meshterm/ui/menus.py +++ b/meshterm/ui/menus.py @@ -543,6 +543,9 @@ async def run_wizard( So the box is pushed once for the whole chain and popped before the answers are returned — whatever the caller opens with them (a full-screen sweep, a session) has nothing floating under it, and Esc from *there* lands where the flow was launched. + And it *is* a box: a popup that happens to be the only frame on the stack (the menu is + popped while a tool runs) would otherwise be drawn full-frame, so the visit floats over + a blank base the way every one-shot dialog does. A step is a callable handed the answers so far (index ``i`` reads ``values[i]``). It returns a :class:`WizardPage` for the box to turn to, or — for a step that is not a @@ -567,7 +570,7 @@ async def run_wizard( if not isinstance(step, WizardPage): raise TypeError("the first step of a wizard must be a WizardPage") screen = SelectScreen(step.title, step.items, prompt=step.prompt, default=step.default) - async with session.stay(screen) as visit: + async with session.stay(screen, dialog=True) as visit: while True: answer = await visit.result() if isinstance(step, WizardPage) else await step if answer is CANCEL or answer is None: diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index c18d50e..c47e67f 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -15,7 +15,7 @@ import asyncio import os import sys from collections.abc import AsyncIterator, Callable, Mapping, Sequence -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, nullcontext from typing import Any from prompt_toolkit.application import Application @@ -667,7 +667,7 @@ class TuiSession: return result @asynccontextmanager - async def stay(self, screen: Screen) -> AsyncIterator[Visit]: + async def stay(self, screen: Screen, *, dialog: bool = False) -> AsyncIterator[Visit]: """Keep ``screen`` pushed for a whole visit while its sub-screens come and go. The counterpart to :meth:`run_screen`, and the shape every screen that *owns a loop* @@ -691,16 +691,21 @@ class TuiSession: Args: screen: The screen to keep pushed for the duration of the block. + dialog: The visit is a *popup's* — a stepped dialog turning its pages + (:func:`~meshterm.ui.menus.run_wizard`) rather than a hub — so it must draw + as a box even when it is the only thing on the stack: a blank base goes under + it for the visit, exactly as :meth:`_run_dialog_screen` does for a one-shot. Yields: A :class:`Visit` whose :meth:`~Visit.result` awaits one round of the screen. """ self._check_unwind() - self.push(screen) - try: - yield Visit(self, screen) - finally: - self.pop(screen) + async with self._floated() if dialog else nullcontext(): + self.push(screen) + try: + yield Visit(self, screen) + finally: + self.pop(screen) def invalidate(self) -> None: """Request a repaint if the application is running.""" @@ -1258,24 +1263,31 @@ class TuiSession: result = await self._run_dialog_screen(screen) return None if result is CANCEL else result - async def _run_dialog_screen(self, screen: Screen) -> Any: - """Run a floating dialog, guaranteeing it draws as a centered box, not full-frame. + @asynccontextmanager + async def _floated(self) -> AsyncIterator[None]: + """Guarantee that whatever is pushed inside the block draws as a box, not full-frame. On an empty stack a lone floating screen is drawn *as* the background — framed - chrome filling the terminal, no popup — so a blank base is pushed beneath it first - (the trick the reconnect dialog and the message popup already use) and popped once - it closes. With a background already present the dialog simply floats over it. + chrome filling the terminal, no popup (see :meth:`_base_index`) — so a blank base + is pushed first (the trick the reconnect dialog and the message popup already use) + and popped once the block ends. With a background already present there is nothing + to do: the dialog simply floats over it. """ backdrop: ScrollScreen | None = None if not self._stack: backdrop = ScrollScreen("", floating=False, footer_hint="") self.push(backdrop) try: - return await self.run_screen(screen) + yield finally: if backdrop is not None: self.pop(backdrop) + async def _run_dialog_screen(self, screen: Screen) -> Any: + """Run a floating dialog as a one-shot, drawn as a centered box (see :meth:`_floated`).""" + async with self._floated(): + return await self.run_screen(screen) + async def typed_confirm(self, warning: str, word: str, *, title: str = "Are you sure?") -> bool: """Gate a destructive action behind typing ``word``; return whether it was typed. diff --git a/tests/test_navigation.py b/tests/test_navigation.py index fb716d3..057cd3e 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -266,11 +266,13 @@ async def test_the_tx_optimize_link_is_one_dialog_gone_before_the_sweep(monkeypa while step not in dialog.title: await asyncio.sleep(0) pages.append(dialog.title) - assert session._stack == [dialog], "one box the whole way through" + assert session._stack[-1] is dialog, "one box the whole way through" + assert len(session._stack) == 2, "floating over a blank base — it is a popup" + assert not session._stack[0].floating and dialog.floating async def main() -> None: run = asyncio.ensure_future(TxOptimizeTool()._run_live(ctx)) - dialog = await _screen_at(session, 1) + dialog = await _screen_at(session, 2) await turned_to(dialog, "step 1 of 2") dialog.resolve("Hilltop-Repeater") await turned_to(dialog, "step 2 of 2") @@ -286,7 +288,7 @@ async def test_the_tx_optimize_link_is_one_dialog_gone_before_the_sweep(monkeypa await asyncio.wait_for(session.run(main()), timeout=5) - assert swept == [("Hilltop-Repeater", "Lakeside", 0)], "nothing floats under the sweep" + assert swept == [("Hilltop-Repeater", "Lakeside", 0)], "nothing at all under the sweep" assert [p.split(" — ")[1] for p in pages] == [ "node to tune · step 1 of 2", "measure at · step 2 of 2", @@ -364,7 +366,9 @@ async def test_a_wizard_turns_one_box_between_its_steps() -> None: Two picks used to be two popups stacked, each a frame to walk back through. A popup is a question, not a place: the chain is one list turning its page, and it is gone - before the caller opens whatever the answers were for. + before the caller opens whatever the answers were for. And it draws as a popup even + on an empty stack — a blank base under it for the visit — where a lone floating + screen would otherwise be painted as the full-frame background. """ from meshterm.ui.menus import WizardPage, run_wizard @@ -387,7 +391,8 @@ async def test_a_wizard_turns_one_box_between_its_steps() -> None: async def main() -> None: run = asyncio.ensure_future(run_wizard(session, [first, second])) - box = await _screen_at(session, 1) + box = await _screen_at(session, 2) + assert not session._stack[0].floating, "a blank base, so the box floats" shown.append((box.title, box._current_choice().value)) box.resolve("b") while "step 2" not in box.title: @@ -400,7 +405,7 @@ async def test_a_wizard_turns_one_box_between_its_steps() -> None: # Turned back: step 2's Esc re-shows step 1 with its answer highlighted. run = asyncio.ensure_future(run_wizard(session, [first, second])) - box = await _screen_at(session, 1) + box = await _screen_at(session, 2) box.resolve("a") while "step 2" not in box.title: await asyncio.sleep(0) @@ -408,7 +413,7 @@ async def test_a_wizard_turns_one_box_between_its_steps() -> None: while "step 1" not in box.title: await asyncio.sleep(0) shown.append((box.title, box._current_choice().value)) - assert session._stack == [box], "the same box, turned back" + assert session._stack[-1] is box, "the same box, turned back" box.resolve(CANCEL) # Esc on the first step abandons assert await asyncio.wait_for(run, timeout=2) is None assert session._stack == [] @@ -446,11 +451,11 @@ async def test_a_wizard_step_may_float_its_own_prompt_over_the_box() -> None: async def main() -> None: run = asyncio.ensure_future(run_wizard(session, [first, second_step])) - box = await _screen_at(session, 1) + box = await _screen_at(session, 2) box.resolve("a") # step 2 backs out at once → step 1 is asked again while box.future is None or box.future.done(): await asyncio.sleep(0) - assert session._stack == [box], "the box never left" + assert session._stack[-1] is box, "the box never left" box.resolve("a") assert await asyncio.wait_for(run, timeout=2) == ["a", "3d"]