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:
1 parent
930596130f
commit
222679c5a3
4 files changed
+49
-25
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
Reference in new issue
Block a user