diff --git a/docs/survey-navigation.md b/docs/survey-navigation.md index 9b1c002..87376d6 100644 --- a/docs/survey-navigation.md +++ b/docs/survey-navigation.md @@ -74,6 +74,15 @@ and `prompt_params` returns only the marker for the interactive path. Trace's pi re-reads its `TRACED` lane after each walk (`ContactListScreen.update_rows`), because that is the column the list is sorted by and a returning trace changes it. +*Revised 2026-09-12 — TX optimize.* Keeping both pickers pushed under the sweep was the +wrong reading of the rule for a **popup**: a popup asks, confirms or informs, and then it is +gone — it is not a place to walk back to, and two of them stacked read as two places. The +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. + **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 mistyped 32-hex channel key cost the name typed before it. `menus.run_steps` now runs a diff --git a/meshterm/tools/tx_optimize.py b/meshterm/tools/tx_optimize.py index 8b74252..ff645f6 100644 --- a/meshterm/tools/tx_optimize.py +++ b/meshterm/tools/tx_optimize.py @@ -5,14 +5,14 @@ measured. The node one hop *before* the target — one you hold admin rights on node whose transmit power gets swept and tuned. Admin passwords are remembered between runs. -In the interactive menu two pickers choose the link — the node to tune (repeaters -you hold credentials for lead the list), then the target whose reception is optimized — -and the live sweep screen (:mod:`meshterm.ui.tx_screen`) takes it from there, armed but -idle: route, range, step, and samples are adjusted in place, nothing transmits until -Sweep is committed, levels land in a bar chart as they are measured, and the apply -decision is made *after* the sweep, over the evidence. On the CLI it stays a scriptable -one-shot with the full flag set (``--path``, range, step, samples, ``--apply``), and -progress streams like a trace. +In the interactive menu one two-step dialog chooses the link — the node to tune +(repeaters you hold credentials for lead the list), then the target whose reception is +optimized — and the live sweep screen (:mod:`meshterm.ui.tx_screen`) takes it from +there, armed but idle: route, range, step, and samples are adjusted in place, nothing +transmits until Sweep is committed, levels land in a bar chart as they are measured, and +the apply decision is made *after* the sweep, over the evidence. On the CLI it stays a +scriptable one-shot with the full flag set (``--path``, range, step, samples, +``--apply``), and progress streams like a trace. """ from __future__ import annotations @@ -46,12 +46,11 @@ class TxOptimizeTool(Tool): order = 20 # like Repeater admin: a remote radio, changed over the mesh async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None: - """Nothing to gather here — both pickers live inside :meth:`run`. + """Nothing to gather here — the link is picked inside :meth:`run`. - Each has to *stay pushed* while what it opens runs, so that Esc walks back down the - entry flow one list at a time instead of dropping to the menu from wherever it is - pressed. A prompt gathered here would resolve — and pop — before the tool ran, so - the two pickers moved into :meth:`_run_live` with the loops that own them. + The two picks are one stepped dialog, and the sweep screen opens the moment it + closes; keeping them together in :meth:`_run_live` keeps the contacts read (the one + slow thing on the way in) shared between the dialog's rows and the sweep's resolver. Args: ctx: Shared application context. @@ -84,8 +83,7 @@ class TxOptimizeTool(Tool): Args: ctx: Shared application context. - params: ``live`` from the menu (both nodes are picked on their own - screens); or ``path``, + params: ``live`` from the menu (both nodes are picked in one dialog); or ``path``, ``samples``, ``tx_min``, ``tx_max``, ``step``, ``apply``, optional ``password``/``viz``, and the injected ``_run_id`` from the CLI. @@ -99,16 +97,16 @@ class TxOptimizeTool(Tool): # -- interactive (menu) --------------------------------------------------------- async def _run_live(self, ctx: AppContext) -> ToolResult: - """Walk the entry flow as a stack: node list, target list, then the sweep screen. + """Pick the link in one stepped dialog, then run the sweep screen over the menu. - Both pickers **stay pushed** for the whole visit, so the flow reads back the way it - was entered: Esc from the sweep lands on the *measure at* list it was launched from, - Esc there lands on the node list, and Esc there leaves for the menu. Each list keeps - its cursor, its filter and its scroll, because it is the same screen object - throughout. They used to be one-shot prompts gathered in ``prompt_params``, both - popped before the sweep opened — so a single Esc from anywhere in the flow landed on - the main menu, and tuning a second target meant reopening the tool and re-picking - the node. + The two picks — the node to tune, then the node to measure at — are one floating + list that turns its page (:func:`~meshterm.ui.menus.run_wizard`): Esc on the + second step turns back to the first with the picked node still highlighted, Esc + on the first leaves. The dialog is gone before the sweep screen opens, so Esc from + the sweep lands on the main menu: the link was a question on the way in, not a + place to come back to. It used to be two pickers, each kept pushed under the + sweep as a hub — three frames deep for a sweep, and two popups stacked on the way + in, which read as two places rather than one question in two parts. No login here: the screen logs in inside the first Sweep commit, so backing out of an idle screen never touched the radio beyond the contact reads. @@ -117,90 +115,63 @@ class TxOptimizeTool(Tool): ctx: Shared application context. Returns: - A :class:`ToolResult` echoing the last sweep's recorded summary. + A :class:`ToolResult` echoing the sweep's recorded summary. """ - from ..ui.admin_picker import admin_node_visit + from ..ui.admin_picker import admin_picker_rows + from ..ui.menus import WizardPage, run_wizard + from ..ui.surface import TuiUi + from ..ui.tx_screen import open_tx_optimize + + if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller + raise RuntimeError("the live TX sweep is only available in the menu") # Through the session cache: this entry flow runs on every open, and the contacts # table is a slow read on a busy node (see # :class:`~meshterm.services.device_state.DeviceState`). contacts = await ctx.devstate.contacts() - summary: dict[str, Any] = {} - async with admin_node_visit( - ctx, - contacts, - title="TX optimize — node to tune", - prompt="Whose transmit power gets tuned (you need its admin password):", - ) as picker: - if picker is None: # nothing offerable — the visit has already said so - return ToolResult(summary={}) - while True: - admin_node = await picker.pick() - if admin_node is None: # Esc — out to the menu - return ToolResult(summary=summary) - summary = await self._measure_visit(ctx, contacts, admin_node) or summary + items, candidates = admin_picker_rows(ctx, contacts) + if not candidates: + ctx.ui.note("[err]no contacts with a key — receive an advert first[/err]") + await ctx.ui.present(title=self.title) + return ToolResult(summary={}) - async def _measure_visit( - self, ctx: AppContext, contacts: list[Contact], admin_node: Contact - ) -> dict[str, Any]: - """Keep the *measure at* list pushed while sweeps run above it. + def admin_of(values: list) -> Contact: + return next(c for c in candidates if c.name == values[0]) - The middle frame of the entry flow: one round per target, so sweeping the same tuned - node against a second target is a pick, not a re-entry. Esc closes the list and hands - back to the node picker underneath. - - Args: - ctx: Shared application context. - contacts: The device's known contacts. - admin_node: The already-picked node whose power gets swept. - - Returns: - The last sweep's summary, or an empty dict if none ran. - """ - from ..ui.surface import TuiUi - from ..ui.tui import SelectScreen - from ..ui.tui.screen import CANCEL - - items = _target_items(contacts, admin_node) - if not items or not isinstance(ctx.ui, TuiUi): - # No other contact to measure at: a typed hex key prefix is the only way in, and - # a one-shot prompt has no list under it to come back to. - entered = await ctx.ui.text( - "Target node", - prompt=f"Name or hex key prefix of the node that hears {admin_node.name}:", + def node_page(values: list) -> WizardPage: + return WizardPage( + title="TX optimize — node to tune · step 1 of 2", + items=items, + prompt="Whose transmit power gets tuned (you need its admin password):", + default=values[0], ) - target = entered.strip() if entered else "" - return await self._sweep(ctx, contacts, admin_node, target) if target else {} - screen = SelectScreen( - "TX optimize — measure at", - items, - prompt=f"The node whose reception of {admin_node.name} gets optimized:", - ) - summary: dict[str, Any] = {} - async with ctx.ui.session.stay(screen) as visit: - while True: - chosen = await visit.result() - if chosen is CANCEL or chosen is None: # Esc — back to the node list - return summary - summary = await self._sweep(ctx, contacts, admin_node, str(chosen)) or summary + def target_page(values: list) -> WizardPage | Any: + admin = admin_of(values) + targets = _target_items(contacts, admin) + if not targets: + # The tuned node is the only contact there is: a typed hex key prefix is the + # only way to name the target, floated over the dialog's first page. + return ctx.ui.session.text( + "TX optimize — measure at · step 2 of 2", + prompt=f"Hex key prefix of the node that hears {admin.name}:", + default=values[1] or "", + floating=True, + ) + return WizardPage( + title="TX optimize — measure at · step 2 of 2", + items=targets, + prompt=f"The node whose reception of {admin.name} gets optimized:", + default=values[1], + ) - @staticmethod - async def _sweep( - ctx: AppContext, contacts: list[Contact], admin_node: Contact, target: str - ) -> dict[str, Any]: - """Open the armed-idle sweep screen for one picked link and return its summary. - - Args: - ctx: Shared application context. - contacts: The device's known contacts (to resolve the target's key). - admin_node: The node whose transmit power gets swept. - target: The target's contact name, or a typed hex key prefix. - - Returns: - The sweep screen's recorded summary (empty if nothing was swept). - """ - from ..ui.tx_screen import open_tx_optimize + answers = await run_wizard(ctx.ui.session, [node_page, target_page]) + if answers is None: # Esc on the first step — out to the menu + return ToolResult(summary={}) + admin_node = admin_of(answers) + target = str(answers[1]).strip() + if not target: + return ToolResult(summary={}) target_contact = next((c for c in contacts if c.name == target), None) if target_contact is not None: @@ -210,12 +181,13 @@ class TxOptimizeTool(Tool): target_label = target target_hash = target # a typed hex prefix stands for itself - return await open_tx_optimize( + summary = await open_tx_optimize( ctx, admin_node=admin_node, target_label=target_label, target_hash=target_hash, ) + return ToolResult(summary=summary) async def _login(self, ctx: AppContext, admin_node: Contact, params: dict[str, Any]) -> None: """Authenticate against the admin node, remembering a working password. @@ -496,7 +468,7 @@ def _target_items(contacts: list[Contact], admin: Contact) -> list: """The *measure at* list's rows, ordered by how recently each node was heard. Rows carry the contact's **name** as their value, since a typed hex prefix stands in the - same place (see :meth:`TxOptimizeTool._sweep`). Empty when the tuned node is the only + same place (see :meth:`TxOptimizeTool._run_live`). Empty when the tuned node is the only contact there is — the caller falls back to a typed prefix. Args: diff --git a/meshterm/ui/menus.py b/meshterm/ui/menus.py index 40682ec..acb9307 100644 --- a/meshterm/ui/menus.py +++ b/meshterm/ui/menus.py @@ -23,6 +23,10 @@ construction: * :func:`confirm_discard` — the shared are-you-sure dialog for leaving staged changes. * :func:`run_steps` — an entry flow of two or more prompts, run as a stack: Esc on one step goes back to the step before it, with what was answered there still in hand. +* :func:`run_wizard` — the same chain drawn in **one** floating list: each step is a + :class:`WizardPage` the box turns to in place (``… · step 1 of 2``), so a flow that + picks two things opens one dialog, not two stacked ones — and the dialog is gone + before the screen it was gathering for opens. House rules the helpers encode (see the UX standards in ``CLAUDE.md``): a list carries no exit row — Esc leaves, and a row repeating it earned nothing for its two lines; the @@ -504,6 +508,86 @@ async def run_steps(steps: Sequence[Callable[[list], Awaitable[Any]]]) -> list | return values +@dataclass(frozen=True) +class WizardPage: + """One step of :func:`run_wizard`: what the shared list shows while that step is asked. + + Attributes: + title: The box's heading for this step — name the feature and the step + (``"TX optimize — node to tune · step 1 of 2"``), since the title is the one + thing that says which page the reader is on. + items: The rows to pick from (:class:`Choice` and :class:`Separator`). + prompt: The one-line question above the rows. + default: The value to highlight — a step come back to hands over its previous + answer here, the same convention :func:`run_steps` gives its steps. + """ + + title: str + items: list + prompt: str = "" + default: Any = None + + +async def run_wizard( + session: Any, + pages: Sequence[Callable[[list], WizardPage | Awaitable[Any]]], +) -> list | None: + """Ask a chain of picks in **one** floating list, turning its page between steps. + + The dialog form of :func:`run_steps`, for a flow whose steps are lists to pick from: + the same stack semantics — Esc on a step goes back to the one before it, with its + answer still highlighted; Esc on the first step abandons — but drawn as a single box + that turns its page (:meth:`~meshterm.ui.tui.select.SelectScreen.turn_page`) rather + than as one popup pushed over another. Two stacked pickers read as two places to be, + and a popup is not a place: it informs, confirms, or chooses, and then it is gone. + 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. + + 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 + list (a typed value where there is nothing to list) — an awaitable that runs its own + prompt, floated over the box on its last page, and resolves to the answer or ``None`` + to step back. The first step must be a page; there is no box yet for anything else to + float over. + + Args: + session: The running :class:`~meshterm.ui.tui.session.TuiSession`. + pages: The steps, in order. + + Returns: + The answers, positionally, or ``None`` if the flow was abandoned. + """ + from .tui import SelectScreen + from .tui.screen import CANCEL + + values: list = [None] * len(pages) + index = 0 + step = pages[0](values) + 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: + while True: + answer = await visit.result() if isinstance(step, WizardPage) else await step + if answer is CANCEL or answer is None: + index -= 1 + if index < 0: + return None + else: + values[index] = answer + index += 1 + if index == len(pages): + return values + # Whichever way it went, the box now shows the step being asked — a page + # turned back to is rebuilt too, so it opens on its previous answer. + step = pages[index](values) + if isinstance(step, WizardPage): + screen.turn_page( + step.items, title=step.title, prompt=step.prompt, default=step.default + ) + + def section_heading(label: str) -> Separator: """A ``── Label ──`` accent section heading for a grouped select list. diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index 4f98b41..4c7295c 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -408,12 +408,45 @@ class SelectScreen(Screen): self._filter = "" # Index into the currently-selectable (filtered) choices. self._index = 0 - if default is not None: - selectable = [it for it in items if isinstance(it, Choice)] - for i, choice in enumerate(selectable): - if choice.value == default: - self._index = i - break + self._highlight(default) + + def _highlight(self, default: Any) -> None: + """Land the highlight on the choice whose value is ``default`` (first row otherwise).""" + self._index = 0 + if default is None: + return + for i, choice in enumerate(self._choices()): + if choice.value == default: + self._index = i + return + + def turn_page(self, items: list, *, title: str, prompt: str = "", default: Any = None) -> None: + """Show a *different* list in the same box — a stepped dialog's next page, or its last. + + The other way a list's rows change under a reader, and the opposite claim from + :meth:`replace_items`: that one refreshes the *same* list (its rows are data that + moved), so the filter and the highlight ride along. A page turned is a new question + — pick the node to tune, now pick where to measure — so nothing rides along: the + query typed to find a row on the last page would narrow this one to nothing it was + meant for, the scroll starts at the top, and the highlight lands on ``default`` (a + page turned *back* to offers its previous answer, exactly as a step of + :func:`~meshterm.ui.menus.run_steps` does) or on the first row. + + Args: + items: The new page's rows, in display order. + title: The new heading — the one place the reader sees which step this is. + prompt: The instruction above the rows, or ``""`` for none. + default: The choice value to highlight, if present. + """ + self._items = items + if self._hscroll_auto: + self._hscroll = any(getattr(item, "hscroll_from", 0) > 0 for item in items) + self.title = title + self._prompt = prompt + self._filter = "" + self._hshift = 0 + self.scroll_to_top() + self._highlight(default) def replace_items( self, items: list, *, title: str | None = None, prompt: str | None = None diff --git a/meshterm/ui/tx_screen.py b/meshterm/ui/tx_screen.py index d3036c5..3c3315f 100644 --- a/meshterm/ui/tx_screen.py +++ b/meshterm/ui/tx_screen.py @@ -1,11 +1,11 @@ """The live TX-optimization screen: pick the link, arm the sweep, watch levels land. The interactive face of the ``tx-optimize`` tool (the scripted CLI keeps its one-shot -table and HTML chart), rebuilt on the trace screen's armed-but-idle pattern. Two pickers -in the tool choose the *link* — the admin node whose transmit power is tuned, then the -target whose reception is optimized — and the screen opens idle: the route, the sweep -parameters, and the login state read across the top, an action list drives everything, -and nothing transmits until Sweep is committed. +table and HTML chart), rebuilt on the trace screen's armed-but-idle pattern. One stepped +dialog in the tool chooses the *link* — the admin node whose transmit power is tuned, +then the target whose reception is optimized — and the screen opens idle over the main +menu: the route, the sweep parameters, and the login state read across the top, an +action list drives everything, and nothing transmits until Sweep is committed. * **Route** opens the trace screen's hop-by-hop path composer, pinned at the tuned node: compose how the measurement reaches it, each step suggested from observed @@ -593,8 +593,9 @@ async def open_tx_optimize( ) -> dict[str, Any]: """Run the live TX-optimization session for one tuned link. - The tool's pickers have chosen the link; this wires the armed-idle screen to the - radio, the topology evidence, and the database, and runs it until dismissed. Login + The tool's dialog has chosen the link and is already gone; this wires the armed-idle + screen to the radio, the topology evidence, and the database, and runs it until + dismissed — and Esc lands on the main menu, the sweep being the whole visit. Login happens inside the first Sweep commit — a remembered password silently, otherwise one floating prompt (remembered on success, forgotten on rejection: the trace composer's convention). Each sweep opens its own ``runs`` row and records every diff --git a/tests/test_navigation.py b/tests/test_navigation.py index 18c664e..fb716d3 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -220,12 +220,13 @@ async def _screen_at(session: TuiSession, depth: int, timeout: float = 2.0): return session._stack[-1] -async def test_the_tx_optimize_pickers_stay_pushed_under_the_sweep(monkeypatch) -> None: - """The two-picker entry flow is a stack: sweep over target list over node list. +async def test_the_tx_optimize_link_is_one_dialog_gone_before_the_sweep(monkeypatch) -> None: + """The two picks are one box turning its page, and the sweep opens over the menu. - Both pickers used to be gathered in ``prompt_params`` and popped before the sweep - opened, so Esc from anywhere in the flow landed on the main menu — and picking a second - target meant reopening the tool and re-picking the node to tune. + The link used to be two pickers stacked one over the other, each kept pushed under + the sweep as a hub — three frames deep for a sweep, and two popups read as two places + to be. A popup is a question, not a place: the dialog is gone before the sweep screen + opens, so Esc from the sweep lands on the main menu. """ from meshterm.core.models import NODE_TYPE_REPEATER, Contact from meshterm.tools.tx_optimize import TxOptimizeTool @@ -247,6 +248,7 @@ async def test_the_tx_optimize_pickers_stay_pushed_under_the_sweep(monkeypatch) ctx = _Ctx() swept: list[tuple[str, str, int]] = [] + pages: list[str] = [] with create_pipe_input() as inp: session = _session(inp) @@ -259,24 +261,39 @@ async def test_the_tx_optimize_pickers_stay_pushed_under_the_sweep(monkeypatch) monkeypatch.setattr("meshterm.ui.tx_screen.open_tx_optimize", fake_sweep) + async def turned_to(dialog, step: str) -> None: + """Spin until the one dialog shows ``step`` — the same object, a new page.""" + while step not in dialog.title: + await asyncio.sleep(0) + pages.append(dialog.title) + assert session._stack == [dialog], "one box the whole way through" + async def main() -> None: run = asyncio.ensure_future(TxOptimizeTool()._run_live(ctx)) - node_list = await _screen_at(session, 1) - node_list.resolve("Hilltop-Repeater") - target_list = await _screen_at(session, 2) - target_list.resolve("Lakeside") - while not swept: - await asyncio.sleep(0) - # Esc from the sweep leaves the target list up; Esc there, the node list. - target_list.resolve(CANCEL) - node_list.resolve(CANCEL) + dialog = await _screen_at(session, 1) + await turned_to(dialog, "step 1 of 2") + dialog.resolve("Hilltop-Repeater") + await turned_to(dialog, "step 2 of 2") + assert [c.value for c in dialog._choices()] == ["Lakeside"], "the tuned node is out" + dialog.resolve(CANCEL) # Esc on step 2 turns back, not out + await turned_to(dialog, "step 1 of 2") + assert dialog._current_choice().value == "Hilltop-Repeater", "still highlighted" + dialog.resolve("Hilltop-Repeater") + await turned_to(dialog, "step 2 of 2") + dialog.resolve("Lakeside") result = await asyncio.wait_for(run, timeout=2) - assert result.summary == {"best": 20}, "the last sweep's summary comes back" + assert result.summary == {"best": 20}, "the sweep's summary comes back" await asyncio.wait_for(session.run(main()), timeout=5) - assert swept == [("Hilltop-Repeater", "Lakeside", 2)], "both pickers under the sweep" - assert session._stack == [], "and both visits pop on the way out" + assert swept == [("Hilltop-Repeater", "Lakeside", 0)], "nothing floats under the sweep" + assert [p.split(" — ")[1] for p in pages] == [ + "node to tune · step 1 of 2", + "measure at · step 2 of 2", + "node to tune · step 1 of 2", + "measure at · step 2 of 2", + ] + assert session._stack == [], "and the dialog is gone on the way out" # --- refreshing a visited list's rows ---------------------------------------- @@ -308,6 +325,138 @@ def test_replace_items_keeps_the_filter_and_follows_the_highlighted_row() -> Non assert screen._current_choice().value == "b", "the highlight followed its row" +def test_turn_page_drops_the_filter_and_highlights_the_named_row() -> None: + """A page turned is a new question, so nothing typed for the last one rides along. + + The opposite claim from ``replace_items``: that one refreshes the same list, this one + shows a different one in the same box. The query typed to find the node to tune would + narrow the *measure at* list to nothing it was meant for, and the highlight lands on + the page's own default — its previous answer, when turned back to — never on a row + that happens to share a value with the one last highlighted. + """ + screen = SelectScreen( + "Link — step 1 of 2", + [Choice("alpha", "a"), Choice("beta", "b"), Choice("gamma", "g")], + ) + screen.handle("text", "b") + assert screen._current_choice().value == "b" + + screen.turn_page( + [Choice("beta", "b"), Choice("gamma", "g")], + title="Link — step 2 of 2", + prompt="Now the other end:", + default="g", + ) + assert screen.title == "Link — step 2 of 2" + assert screen._prompt == "Now the other end:" + assert screen._filter == "", "the last page's query is gone" + assert screen._current_choice().value == "g", "the page's own default, not the old row" + + screen.turn_page([Choice("alpha", "a"), Choice("beta", "b")], title="Link — step 1 of 2") + assert screen._current_choice().value == "a", "no default: the first row" + + +# --- a wizard is a stepped chain in one box ------------------------------------ + + +async def test_a_wizard_turns_one_box_between_its_steps() -> None: + """Esc on a later step turns back a page; the box is pushed once and popped once. + + 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. + """ + from meshterm.ui.menus import WizardPage, run_wizard + + shown: list[tuple[str, str]] = [] + + def first(values: list) -> WizardPage: + return WizardPage( + "Link — step 1 of 2", [Choice("alpha", "a"), Choice("beta", "b")], default=values[0] + ) + + def second(values: list) -> WizardPage: + return WizardPage( + f"Link — after {values[0]} · step 2 of 2", + [Choice("gamma", "g"), Choice("delta", "d")], + default=values[1], + ) + + with create_pipe_input() as inp: + session = _session(inp) + + async def main() -> None: + run = asyncio.ensure_future(run_wizard(session, [first, second])) + box = await _screen_at(session, 1) + shown.append((box.title, box._current_choice().value)) + box.resolve("b") + while "step 2" not in box.title: + await asyncio.sleep(0) + shown.append((box.title, box._current_choice().value)) + box.resolve("d") + answers = await asyncio.wait_for(run, timeout=2) + assert answers == ["b", "d"] + assert session._stack == [], "popped before the answers come back" + + # 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.resolve("a") + while "step 2" not in box.title: + await asyncio.sleep(0) + box.resolve(CANCEL) + 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" + box.resolve(CANCEL) # Esc on the first step abandons + assert await asyncio.wait_for(run, timeout=2) is None + assert session._stack == [] + + await asyncio.wait_for(session.run(main()), timeout=5) + + assert shown == [ + ("Link — step 1 of 2", "a"), + ("Link — after b · step 2 of 2", "g"), + ("Link — step 1 of 2", "a"), + ] + + +async def test_a_wizard_step_may_float_its_own_prompt_over_the_box() -> None: + """A step with nothing to list (a typed value) runs as an awaitable above the box. + + The box stays on its last page underneath — it is the backdrop the prompt floats over + — and ``None`` from the prompt steps back exactly as Esc on a page would. + """ + from meshterm.ui.menus import WizardPage, run_wizard + + typed = iter([None, "3d"]) # Esc the first time, then a hex prefix + + def first(values: list) -> WizardPage: + return WizardPage("Pick — step 1 of 2", [Choice("alpha", "a")], default=values[0]) + + async def second(values: list) -> str | None: + return next(typed) + + def second_step(values: list): + return second(values) + + with create_pipe_input() as inp: + session = _session(inp) + + async def main() -> None: + run = asyncio.ensure_future(run_wizard(session, [first, second_step])) + box = await _screen_at(session, 1) + 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" + box.resolve("a") + assert await asyncio.wait_for(run, timeout=2) == ["a", "3d"] + + await asyncio.wait_for(session.run(main()), timeout=5) + + def test_replace_items_clamps_to_the_position_when_the_row_is_gone() -> None: """Deleting the row you were on leaves you where it was, not back at the top.""" screen = SelectScreen("Queue", [Choice(name, name) for name in ("a", "b", "c", "d")])