The TX optimize link is picked in one dialog that turns its page, gone before the sweep

Two pickers stacked one over the other, each kept pushed under the sweep as a hub, read
as two places to be — and a popup is not a place: it asks, confirms or informs, then it
is gone. The node to tune and the node to measure at are now one floating list that turns
its page ("… · 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 screen opens, so Esc from the sweep lands on the main menu.

menus.run_wizard is the dialog form of run_steps — the same stack semantics drawn in one
box — and SelectScreen.turn_page is what it turns on: the opposite claim from
replace_items, since a page turned is a new question and neither the typed filter nor the
highlight rides along. A step with nothing to list (the typed hex prefix when the tuned
node is the only contact) runs as an awaitable floated over the box's last page.

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:52:37 -04:00
1 parent e24e41178a
commit 930596130f
6 files changed
+377 -129

No files matched your search

+9
View File
@@ -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
+71 -99
View File
@@ -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:
+84
View File
@@ -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.
+39 -6
View File
@@ -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
+8 -7
View File
@@ -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
+166 -17
View File
@@ -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")])