The stepped dialog draws as a popup, not as the frame

The menu is popped while a tool runs, so a floating screen that is the only frame on the
stack was painted as the full-frame background — the TX optimize link dialog opened as
a page and only its sub-prompts looked like popups. The blank base _run_dialog_screen
puts under a one-shot dialog is now one context manager, and stay(dialog=True) puts the
same base under a popup's whole visit, which is what run_wizard asks for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5.1 committed 2026-09-12 01:58:38 -04:00
1 parent 930596130f
commit 222679c5a3
4 files changed
+49 -25

No files matched your search

+6 -2
View File
@@ -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
+4 -1
View File
@@ -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:
+25 -13
View File
@@ -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.
+14 -9
View File
@@ -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"]