Dialogs: float confirms/prompts over their backdrop, never replace the screen
Enforce the UX standard "modal popups over the pushed backdrop" across three flows that were drawing a full-frame replacement instead of a floating popup: - Trophy case: keep the record browser pushed as a static backdrop while its delete-record / delete-discipline / delete-all confirms float over it, and run any trace hand-off after popping. - Device picker: pass the picker's rows through to the removal confirm so it floats over a redraw of the list (backdrop_items/backdrop_default on confirm_startup), with the row being removed left highlighted. - Remote-admin password: add an opt-in floating flag to Ui.text so a mid-flow password prompt draws as a centered popup over a blank base instead of the full frame; apply it to repeater_admin and tx_optimize logins. Tool-entry text prompts (Trace/tx target fallbacks) stay full-frame. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
115fe6895d
commit
59b26ae616
8 files changed
+395
-76
No files matched your search
@@ -309,7 +309,7 @@ class TxOptimizeTool(Tool):
|
||||
"interactively to store it."
|
||||
)
|
||||
entered = await ctx.ui.text(
|
||||
f"Admin password for {admin_node.name}:", password=True
|
||||
f"Admin password for {admin_node.name}:", password=True, floating=True
|
||||
)
|
||||
if not entered:
|
||||
raise DeviceCommandError("an admin password is required to tune a remote node.")
|
||||
|
||||
@@ -296,9 +296,12 @@ async def prompt_device(
|
||||
# Preselect the remembered "last known good" device when it is currently attached/in range.
|
||||
default = next((d for d in listed if remembered and remembered.matches(d)), None)
|
||||
|
||||
# Build the rows once so the same list can be redrawn as the backdrop behind a
|
||||
# removal confirm (so it floats over the picker rather than replacing it).
|
||||
items = _build_items(listed, remembered, registry)
|
||||
chosen = await ui.select_startup(
|
||||
"Select a companion device",
|
||||
_build_items(listed, remembered, registry),
|
||||
items,
|
||||
default=default,
|
||||
banner=load_logo(),
|
||||
footnote=copyright_notice(),
|
||||
@@ -318,8 +321,11 @@ async def prompt_device(
|
||||
|
||||
if isinstance(chosen, DeleteRequest):
|
||||
# Delete was pressed on a removable (network) row: confirm, forget, and re-draw
|
||||
# the list — the row's disappearance is the visible feedback.
|
||||
await _remove_network_device(ui, store, registry, chosen.value)
|
||||
# the list — the row's disappearance is the visible feedback. The list is passed
|
||||
# through so the confirm floats over it (the row it removes stays highlighted).
|
||||
await _remove_network_device(
|
||||
ui, store, registry, chosen.value, backdrop_items=items
|
||||
)
|
||||
continue
|
||||
|
||||
name = _display_name(chosen, registry)
|
||||
@@ -426,20 +432,26 @@ async def _remove_network_device(
|
||||
store: DeviceStore,
|
||||
registry: dict[str, RememberedDevice],
|
||||
device: DiscoveredDevice,
|
||||
*,
|
||||
backdrop_items: list,
|
||||
) -> None:
|
||||
"""Confirm and forget a remembered network (TCP) device, dropping it from the picker.
|
||||
|
||||
Removal is offered only on network rows: a TCP companion is listed solely from its
|
||||
remembered endpoint, so forgetting it is what makes it leave the picker — a scanned serial
|
||||
or BLE device would just reappear on the next scan. Opens a cautionary Cancel/Remove
|
||||
confirm on the splash; on Remove the record is pruned from the store, on Cancel/Esc nothing
|
||||
changes. Either way the caller re-opens the list, so the row's absence is the feedback.
|
||||
or BLE device would just reappear on the next scan. Opens the reserved-red Cancel/Remove
|
||||
confirm as a modal popup floating over the device list (``backdrop_items``, with the row
|
||||
being removed left highlighted), so it reads as a dialog on top of the picker rather than a
|
||||
splash that replaces it. On Remove the record is pruned from the store, on Cancel/Esc
|
||||
nothing changes. Either way the caller re-opens the list, so the row's absence is the
|
||||
feedback.
|
||||
|
||||
Args:
|
||||
ui: The interactive surface for the confirm dialog.
|
||||
store: The confirmed-device registry to prune.
|
||||
registry: The current registry, for naming the device in the prompt.
|
||||
device: The network device the user asked to remove.
|
||||
backdrop_items: The picker's rows, redrawn behind the floating confirm.
|
||||
"""
|
||||
if not device.is_tcp:
|
||||
return # defensive: only network rows opt into deletion (see _build_items)
|
||||
@@ -450,6 +462,8 @@ async def _remove_network_device(
|
||||
confirm_label="Remove",
|
||||
banner=load_logo(),
|
||||
footnote=copyright_notice(),
|
||||
backdrop_items=backdrop_items,
|
||||
backdrop_default=device,
|
||||
)
|
||||
if confirmed:
|
||||
store.forget(device.stable_id)
|
||||
|
||||
@@ -671,49 +671,60 @@ async def open_records(ctx: "AppContext") -> dict:
|
||||
value=("del_all", None, 0, None),
|
||||
))
|
||||
items.extend(back_rows(("back", None, 0, None)))
|
||||
picked = await session.run_screen(
|
||||
SelectScreen(
|
||||
"Trophy case",
|
||||
items,
|
||||
footer_hint="↑↓ move · Enter open · Esc back",
|
||||
wrap=False,
|
||||
)
|
||||
browser = SelectScreen(
|
||||
"Trophy case",
|
||||
items,
|
||||
footer_hint="↑↓ move · Enter open · Esc back",
|
||||
wrap=False,
|
||||
)
|
||||
picked = await session.run_screen(browser)
|
||||
if picked is CANCEL or picked is None or picked[0] == "back":
|
||||
return {"records": total}
|
||||
verb = picked[0]
|
||||
if verb == "del_cat":
|
||||
await delete_category_flow()
|
||||
continue
|
||||
if verb == "del_all":
|
||||
if not total:
|
||||
continue
|
||||
if await session.typed_confirm(
|
||||
f"This deletes all {total} records — every discipline, every width. "
|
||||
"They can only be re-earned by walking them again.",
|
||||
"delete",
|
||||
title="Delete all records",
|
||||
):
|
||||
ctx.repo.delete_discoveries()
|
||||
continue
|
||||
_verb, category, rank, record = picked
|
||||
far_label, far_id, shape = walk_drawing(record)
|
||||
action = await session.run_screen(RecordDialog(
|
||||
record, category, rank,
|
||||
resolve=resolve, device_label=device_label, device_hash=device_hash,
|
||||
far_label=far_label, shape=shape,
|
||||
reliability=walk_reliability(far_id, far_label),
|
||||
type_of=lambda node_id: node_geo(node_id)[1],
|
||||
))
|
||||
if action == "trace":
|
||||
await open_trace_path(ctx, spec=record.spec)
|
||||
elif action == "delete":
|
||||
sure = await ctx.ui.dialog(
|
||||
f"Delete this {category.title} record?",
|
||||
[("Cancel", False), ("Delete", True)],
|
||||
title="Delete record",
|
||||
default=1,
|
||||
destructive=True,
|
||||
)
|
||||
if sure:
|
||||
ctx.repo.delete_discovery(record.id)
|
||||
# Every pick opens a dialog that belongs *over* the trophy case — the discipline
|
||||
# picker, the delete confirms, a record's floating story. run_screen just popped the
|
||||
# browser, so re-push it as the static backdrop: it stays drawn full-frame behind the
|
||||
# dialog (which, run over the bare stack, would otherwise draw over a blank frame — or,
|
||||
# alone and floating, become the frame). This is the device picker's trick
|
||||
# (confirm_startup) and what RecordDialog's "floating over the screen beneath" assumes.
|
||||
session.push(browser)
|
||||
trace_spec: Optional[str] = None
|
||||
try:
|
||||
verb = picked[0]
|
||||
if verb == "del_cat":
|
||||
await delete_category_flow()
|
||||
elif verb == "del_all":
|
||||
if total and await session.typed_confirm(
|
||||
f"This deletes all {total} records — every discipline, every width. "
|
||||
"They can only be re-earned by walking them again.",
|
||||
"delete",
|
||||
title="Delete all records",
|
||||
):
|
||||
ctx.repo.delete_discoveries()
|
||||
else:
|
||||
_verb, category, rank, record = picked
|
||||
far_label, far_id, shape = walk_drawing(record)
|
||||
action = await session.run_screen(RecordDialog(
|
||||
record, category, rank,
|
||||
resolve=resolve, device_label=device_label, device_hash=device_hash,
|
||||
far_label=far_label, shape=shape,
|
||||
reliability=walk_reliability(far_id, far_label),
|
||||
type_of=lambda node_id: node_geo(node_id)[1],
|
||||
))
|
||||
if action == "trace":
|
||||
trace_spec = record.spec
|
||||
elif action == "delete":
|
||||
sure = await ctx.ui.dialog(
|
||||
f"Delete this {category.title} record?",
|
||||
[("Cancel", False), ("Delete", True)],
|
||||
title="Delete record",
|
||||
default=1,
|
||||
destructive=True,
|
||||
)
|
||||
if sure:
|
||||
ctx.repo.delete_discovery(record.id)
|
||||
finally:
|
||||
session.pop(browser)
|
||||
# Tracing a path is a full hand-off to the Trace screen, not a dialog over the
|
||||
# trophy case, so it runs only once the browser backdrop is down.
|
||||
if trace_spec is not None:
|
||||
await open_trace_path(ctx, spec=trace_spec)
|
||||
@@ -116,6 +116,7 @@ async def _login(ctx: "AppContext", device: "Device", node: Contact) -> bool:
|
||||
f"Admin password for {node.name}",
|
||||
prompt="The node ignores admin commands without a login.",
|
||||
password=True,
|
||||
floating=True, # a modal step between the node picker and the admin menu
|
||||
)
|
||||
if not password:
|
||||
return False
|
||||
|
||||
+23
-2
@@ -144,8 +144,14 @@ class Ui:
|
||||
confirm_label: str = "Remove",
|
||||
banner: Any = None,
|
||||
footnote: Optional[str] = None,
|
||||
backdrop_items: Optional[list] = None,
|
||||
backdrop_default: Any = None,
|
||||
) -> bool:
|
||||
"""Confirm a destructive action on the startup splash; ``True`` only if committed."""
|
||||
"""Confirm a destructive action on the startup splash; ``True`` only if committed.
|
||||
|
||||
``backdrop_items`` (the picker's rows) floats the confirm over a redrawn device list;
|
||||
``backdrop_default`` pre-highlights the row it acts on.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def notify_startup(
|
||||
@@ -210,11 +216,15 @@ class Ui:
|
||||
validate: Optional[Validator] = None,
|
||||
help_text: str = "",
|
||||
password: bool = False,
|
||||
floating: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Prompt for a line of text; return it or ``None`` if cancelled.
|
||||
|
||||
Keep ``title`` short (it heads the popup's border) and put the question/instruction
|
||||
in ``prompt`` (drawn above the field), so a text popup reads like the button dialogs.
|
||||
``floating`` forces the prompt to draw as a centered popup even with nothing beneath
|
||||
it (a mid-flow modal such as a remote-admin password) instead of filling the frame;
|
||||
surfaces without a screen stack ignore it.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -408,6 +418,8 @@ class PlainUi(Ui):
|
||||
confirm_label: str = "Remove",
|
||||
banner: Any = None,
|
||||
footnote: Optional[str] = None,
|
||||
backdrop_items: Optional[list] = None,
|
||||
backdrop_default: Any = None,
|
||||
) -> bool:
|
||||
"""Unsupported in scripted CLI mode — the picker splash is interactive-only."""
|
||||
raise self._no_prompt()
|
||||
@@ -478,12 +490,15 @@ class PlainUi(Ui):
|
||||
validate: Optional[Validator] = None,
|
||||
help_text: str = "",
|
||||
password: bool = False,
|
||||
floating: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Prompt on the terminal (line editor / getpass), re-asking until valid.
|
||||
|
||||
A few tools (e.g. a remote-admin password) can legitimately prompt from a scripted
|
||||
run when no flag was supplied, so this stays functional on the CLI. An in-body
|
||||
``prompt`` (used by the interactive popups) is printed once as a lead-in line here.
|
||||
``floating`` is a full-screen-popup nicety with no meaning on the plain terminal, so
|
||||
it is accepted and ignored.
|
||||
|
||||
Returns:
|
||||
The entered string, or ``None`` on EOF / interrupt.
|
||||
@@ -652,14 +667,18 @@ class TuiUi(Ui):
|
||||
confirm_label: str = "Remove",
|
||||
banner: Any = None,
|
||||
footnote: Optional[str] = None,
|
||||
backdrop_items: Optional[list] = None,
|
||||
backdrop_default: Any = None,
|
||||
) -> bool:
|
||||
"""Delegate to the session's chromeless startup confirm dialog."""
|
||||
"""Delegate to the session's startup confirm dialog (floated over the picker)."""
|
||||
return await self.session.confirm_startup(
|
||||
prompt,
|
||||
title=title,
|
||||
confirm_label=confirm_label,
|
||||
banner=banner,
|
||||
footnote=footnote,
|
||||
backdrop_items=backdrop_items,
|
||||
backdrop_default=backdrop_default,
|
||||
)
|
||||
|
||||
async def notify_startup(
|
||||
@@ -738,6 +757,7 @@ class TuiUi(Ui):
|
||||
validate: Optional[Validator] = None,
|
||||
help_text: str = "",
|
||||
password: bool = False,
|
||||
floating: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Delegate to the session's text screen."""
|
||||
return await self.session.text(
|
||||
@@ -747,6 +767,7 @@ class TuiUi(Ui):
|
||||
validate=validate,
|
||||
help_text=help_text,
|
||||
password=password,
|
||||
floating=floating,
|
||||
)
|
||||
|
||||
async def confirm(self, title: str, *, default: bool = True) -> Optional[bool]:
|
||||
|
||||
+63
-20
@@ -368,16 +368,26 @@ class TuiSession:
|
||||
confirm_label: str = "Remove",
|
||||
banner: Optional[Any] = None,
|
||||
footnote: Optional[str] = None,
|
||||
backdrop_items: Optional[list] = None,
|
||||
backdrop_default: Any = None,
|
||||
backdrop_title: str = "Select a companion device",
|
||||
footer_hint: str = "←→ choose · Enter select · Esc cancel",
|
||||
) -> bool:
|
||||
"""Confirm a destructive splash action with a Cancel/verb dialog (chromeless).
|
||||
|
||||
The startup-splash sibling of :meth:`button_dialog`: the same platform-dialog layout
|
||||
— the safe *Cancel* on the left and the committing verb on the right and default, so
|
||||
Enter commits and Esc backs out — but drawn without the status bars and centered under
|
||||
``banner``, so it reads as part of the device-selection flow it floats over. Themed
|
||||
as data loss (the reserved red prompt and border), since it only ever gates forgetting
|
||||
a remembered device — the splash sibling of a ``destructive`` :meth:`button_dialog`.
|
||||
Enter commits and Esc backs out. Themed as data loss (the reserved red prompt and
|
||||
border), since it only ever gates forgetting a remembered device — the splash sibling
|
||||
of a ``destructive`` :meth:`button_dialog`.
|
||||
|
||||
When ``backdrop_items`` is given, the device list they describe is redrawn as the
|
||||
chromeless base and the red confirm *floats over it* as a centred box (a modal popup
|
||||
over the pushed backdrop, the way every other dialog behaves) — so removing a device
|
||||
reads as a popup on top of the picker rather than a splash that replaces it. The
|
||||
``backdrop_default`` row is pre-highlighted so the confirm reads as being about it.
|
||||
Without ``backdrop_items`` the confirm draws as its own chromeless splash under
|
||||
``banner`` (the fallback for a caller with no list to float over).
|
||||
|
||||
Args:
|
||||
prompt: The question shown above the buttons.
|
||||
@@ -385,12 +395,16 @@ class TuiSession:
|
||||
confirm_label: Label for the committing button (e.g. ``"Remove"``).
|
||||
banner: Wordmark rows drawn above the box (as on the other startup splashes).
|
||||
footnote: Muted line drawn below the box.
|
||||
backdrop_items: The picker's rows to redraw behind the confirm; ``None`` falls
|
||||
back to a standalone chromeless confirm splash.
|
||||
backdrop_default: The row value to pre-highlight in the backdrop list.
|
||||
backdrop_title: Heading for the backdrop list (the picker's own title).
|
||||
footer_hint: Footer key hint.
|
||||
|
||||
Returns:
|
||||
``True`` only when the user chose the committing button; ``False`` on Cancel/Esc.
|
||||
"""
|
||||
screen = ButtonDialog(
|
||||
dialog = ButtonDialog(
|
||||
prompt,
|
||||
[("Cancel", False), (confirm_label, True)],
|
||||
title=title,
|
||||
@@ -399,11 +413,31 @@ class TuiSession:
|
||||
prompt_style="err",
|
||||
border_style="err",
|
||||
)
|
||||
screen.chrome = False
|
||||
screen.banner = banner
|
||||
screen.footnote = footnote
|
||||
result = await self.run_screen(screen)
|
||||
return result is True
|
||||
if backdrop_items is None:
|
||||
# No list to float over: draw the confirm as its own chromeless splash.
|
||||
dialog.chrome = False
|
||||
dialog.banner = banner
|
||||
dialog.footnote = footnote
|
||||
return await self.run_screen(dialog) is True
|
||||
# Keep the picker on screen as the chromeless base and float the red confirm over it,
|
||||
# so the removal confirm sits *on top of* the device list it acts on. The backdrop is
|
||||
# a static redraw of the same rows (it never takes a key — the dialog above owns input).
|
||||
backdrop = SelectScreen(
|
||||
backdrop_title,
|
||||
backdrop_items,
|
||||
default=backdrop_default,
|
||||
footer_hint="↑↓ move · Enter select · Esc quit",
|
||||
delete_hint="Del remove",
|
||||
filterable=False,
|
||||
)
|
||||
backdrop.chrome = False
|
||||
backdrop.banner = banner
|
||||
backdrop.footnote = footnote
|
||||
self.push(backdrop)
|
||||
try:
|
||||
return await self.run_screen(dialog) is True
|
||||
finally:
|
||||
self.pop(backdrop)
|
||||
|
||||
async def notify_startup(
|
||||
self,
|
||||
@@ -567,18 +601,27 @@ class TuiSession:
|
||||
validate: Optional[Validator] = None,
|
||||
help_text: str = "",
|
||||
password: bool = False,
|
||||
floating: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Show a text prompt; return the string or ``None`` if cancelled."""
|
||||
result = await self.run_screen(
|
||||
TextScreen(
|
||||
title,
|
||||
prompt=prompt,
|
||||
default=default,
|
||||
validate=validate,
|
||||
help_text=help_text,
|
||||
password=password,
|
||||
)
|
||||
"""Show a text prompt; return the string or ``None`` if cancelled.
|
||||
|
||||
``floating`` guarantees the prompt draws as a centered popup even on an empty stack
|
||||
— a modal step mid-flow (a remote-admin password) rather than a tool's full-frame
|
||||
entry screen. It then floats over a blank base the way :meth:`button_dialog` and
|
||||
:meth:`typed_confirm` do (see :meth:`_run_dialog_screen`); with a screen already
|
||||
beneath it there is no difference, so leave it ``False`` for a prompt that is itself
|
||||
a tool's primary screen (the Trace target's typed fallback).
|
||||
"""
|
||||
screen = TextScreen(
|
||||
title,
|
||||
prompt=prompt,
|
||||
default=default,
|
||||
validate=validate,
|
||||
help_text=help_text,
|
||||
password=password,
|
||||
)
|
||||
runner = self._run_dialog_screen if floating else self.run_screen
|
||||
result = await runner(screen)
|
||||
return None if result is CANCEL else result
|
||||
|
||||
async def confirm(self, title: str, *, default: bool = True) -> Optional[bool]:
|
||||
|
||||
@@ -7,12 +7,27 @@ are all assertable without a terminal.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from meshterm.persistence.repository import DiscoveredPath
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from meshterm.context import AppContext
|
||||
from meshterm.core.admin_store import AdminStore
|
||||
from meshterm.core.config import Settings
|
||||
from meshterm.core.device_store import DeviceStore
|
||||
from meshterm.persistence.repository import DiscoveredPath, Repository
|
||||
from meshterm.services.records import CATEGORY_BY_ID
|
||||
from meshterm.ui.records_screen import RecordDialog, WalkVertex
|
||||
from meshterm.ui.records_screen import RecordDialog, WalkVertex, open_records
|
||||
from meshterm.ui.surface import TuiUi
|
||||
from meshterm.ui.tui.prompt import TypedConfirmDialog
|
||||
from meshterm.ui.tui.screen import CANCEL
|
||||
from meshterm.ui.tui.select import SelectScreen
|
||||
from meshterm.ui.tui.session import TuiSession
|
||||
from meshterm.ui.widgets import node_marker, self_marker
|
||||
|
||||
HUB_ID, FAR_ID = "3d63c6429436", "f2c24f54551e"
|
||||
@@ -197,3 +212,119 @@ def test_record_dialog_arrows_follow_the_action_cursor() -> None:
|
||||
assert dialog._follow
|
||||
dialog.render_body(60) # the frame renders before reading the cursor
|
||||
assert dialog.cursor_line() is not None
|
||||
|
||||
|
||||
# --- the browser loop: dialogs float over the trophy case, never a blank frame ------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tui_ctx(tmp_path: Path) -> AppContext:
|
||||
"""A mock-backed context wired to a headless TUI session (no prompt_toolkit app)."""
|
||||
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "records.db")
|
||||
ctx = AppContext(
|
||||
console=Console(file=io.StringIO()),
|
||||
settings=settings,
|
||||
repo=Repository(settings.db_path),
|
||||
device_store=DeviceStore(tmp_path / "devices.json"),
|
||||
admin_store=AdminStore(tmp_path / "admin.json"),
|
||||
mock=True,
|
||||
)
|
||||
ctx.ui = TuiUi(TuiSession())
|
||||
yield ctx
|
||||
ctx.repo.close()
|
||||
|
||||
|
||||
async def _step_until(predicate, *, limit: int = 200):
|
||||
"""Yield to the event loop until ``predicate()`` is truthy; return it (or its last value)."""
|
||||
value = predicate()
|
||||
for _ in range(limit):
|
||||
if value:
|
||||
return value
|
||||
await asyncio.sleep(0)
|
||||
value = predicate()
|
||||
return value
|
||||
|
||||
|
||||
def _trophy_case(session, *, other_than=None):
|
||||
"""The trophy-case browser on top of the stack, if it is the frontmost screen."""
|
||||
top = session.top
|
||||
if isinstance(top, SelectScreen) and top.title == "Trophy case" and top is not other_than:
|
||||
return top
|
||||
return None
|
||||
|
||||
|
||||
async def test_delete_all_confirm_floats_over_the_browser(tui_ctx) -> None:
|
||||
"""The trophy case stays drawn full-frame behind the "Delete all records" confirm.
|
||||
|
||||
Regression: the browser was run *then popped* before the confirm opened, so the confirm
|
||||
floated over a blank frame — the background "cleared". Re-pushing the browser as the
|
||||
backdrop keeps it the full-frame base while the red typed-delete gate floats over it.
|
||||
"""
|
||||
ctx = tui_ctx
|
||||
session = ctx.ui.session
|
||||
ctx.repo.record_discovery(
|
||||
"grand_tour", 1, "3d,f2", (HUB_ID, FAR_ID),
|
||||
score=2.0, stats={"hop_count": 2, "distinct_nodes": 2}, app_version="0.1.0",
|
||||
)
|
||||
|
||||
task = asyncio.ensure_future(open_records(ctx))
|
||||
try:
|
||||
browser = await _step_until(lambda: _trophy_case(session))
|
||||
assert browser is not None, "the trophy case list never opened"
|
||||
|
||||
browser.resolve(("del_all", None, 0, None)) # choose "Delete all records"
|
||||
confirm = await _step_until(
|
||||
lambda: session._float_layers()[0] if session._has_float() else None
|
||||
)
|
||||
assert isinstance(confirm, TypedConfirmDialog) # the red typed-delete gate
|
||||
assert session._base_screen() is browser # the browser is still the full-frame base
|
||||
|
||||
confirm.resolve(CANCEL) # Esc — back out without deleting
|
||||
again = await _step_until(lambda: _trophy_case(session, other_than=browser))
|
||||
assert again is not None, "the browser never reopened after the confirm"
|
||||
again.resolve(("back", None, 0, None)) # leave the trophy case
|
||||
result = await task
|
||||
finally:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
assert result == {"records": 1} # backed out — the record still stands
|
||||
|
||||
|
||||
async def test_delete_a_disciplines_records_is_a_popup_over_the_browser(tui_ctx) -> None:
|
||||
"""Picking a discipline to delete floats a popup over the browser, not a full-screen list.
|
||||
|
||||
The discipline picker is a floating :class:`SelectScreen`; with the browser kept on the
|
||||
stack it draws as a centered popup over the visible trophy case (``_base_screen`` stays
|
||||
the browser) instead of replacing the whole frame.
|
||||
"""
|
||||
ctx = tui_ctx
|
||||
session = ctx.ui.session
|
||||
ctx.repo.record_discovery(
|
||||
"grand_tour", 1, "3d,f2", (HUB_ID, FAR_ID),
|
||||
score=2.0, stats={"hop_count": 2, "distinct_nodes": 2}, app_version="0.1.0",
|
||||
)
|
||||
|
||||
task = asyncio.ensure_future(open_records(ctx))
|
||||
try:
|
||||
browser = await _step_until(lambda: _trophy_case(session))
|
||||
assert browser is not None
|
||||
|
||||
browser.resolve(("del_cat", None, 0, None)) # "Delete a discipline's records…"
|
||||
picker = await _step_until(
|
||||
lambda: session._float_layers()[0] if session._has_float() else None
|
||||
)
|
||||
assert isinstance(picker, SelectScreen) # a floating popup, not the frame
|
||||
assert picker.title == "Delete a discipline's records"
|
||||
assert session._base_screen() is browser # the browser stays behind it
|
||||
|
||||
picker.resolve(CANCEL) # Esc — abandon the pick
|
||||
again = await _step_until(lambda: _trophy_case(session, other_than=browser))
|
||||
assert again is not None
|
||||
again.resolve(("back", None, 0, None))
|
||||
result = await task
|
||||
finally:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
assert result == {"records": 1}
|
||||
+100
-2
@@ -965,8 +965,13 @@ def test_device_picker_removes_network_device_on_delete(tmp_path) -> None:
|
||||
return _QUIT
|
||||
|
||||
async def confirm_startup(self, prompt, *, title="", confirm_label="Remove",
|
||||
banner=None, footnote=None):
|
||||
banner=None, footnote=None, backdrop_items=None,
|
||||
backdrop_default=None):
|
||||
confirmed_prompts.append(prompt)
|
||||
# The confirm floats over the picker: it's handed the rows to redraw behind it,
|
||||
# with the row being removed pre-highlighted.
|
||||
assert backdrop_items is not None
|
||||
assert getattr(backdrop_default, "is_tcp", False)
|
||||
return True # the user confirms the removal
|
||||
|
||||
async def _never(_device, _pin=None):
|
||||
@@ -1006,7 +1011,8 @@ def test_device_picker_keeps_network_device_when_removal_cancelled(tmp_path) ->
|
||||
return _QUIT
|
||||
|
||||
async def confirm_startup(self, prompt, *, title="", confirm_label="Remove",
|
||||
banner=None, footnote=None):
|
||||
banner=None, footnote=None, backdrop_items=None,
|
||||
backdrop_default=None):
|
||||
return False # the user backs out (Cancel / Esc)
|
||||
|
||||
async def _never(_device, _pin=None):
|
||||
@@ -1018,6 +1024,46 @@ def test_device_picker_keeps_network_device_when_removal_cancelled(tmp_path) ->
|
||||
assert remembered is not None and remembered.node_name == "WifiNode"
|
||||
|
||||
|
||||
def test_confirm_startup_floats_red_over_the_picker_backdrop() -> None:
|
||||
"""The removal confirm floats as a red popup over a redrawn picker, not a full splash."""
|
||||
from meshterm.core.discovery import tcp_device
|
||||
|
||||
session = TuiSession()
|
||||
device = tcp_device("192.168.1.50", 5000, name="WifiNode")
|
||||
items = [Choice("WifiNode (192.168.1.50:5000)", device, deletable=True)]
|
||||
|
||||
async def main() -> None:
|
||||
task = asyncio.ensure_future(
|
||||
session.confirm_startup(
|
||||
"Remove WifiNode?",
|
||||
title="Remove network device",
|
||||
banner=["MESHTERM"],
|
||||
backdrop_items=items,
|
||||
backdrop_default=device,
|
||||
)
|
||||
)
|
||||
# Let confirm_startup push the backdrop list and float the confirm over it.
|
||||
for _ in range(3):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
base = session._base_screen()
|
||||
floats = session._float_layers()
|
||||
# The picker is redrawn as the chromeless base; the confirm floats over it (not a
|
||||
# full-screen splash that replaces the list).
|
||||
assert isinstance(base, SelectScreen) and base.chrome is False
|
||||
assert len(floats) == 1
|
||||
dialog = floats[0]
|
||||
assert isinstance(dialog, ButtonDialog)
|
||||
assert dialog.border_style == "err" # the reserved data-loss red
|
||||
|
||||
dialog.resolve(True) # commit the removal
|
||||
result = await task
|
||||
assert result is True
|
||||
assert session._stack == [] # the backdrop is torn down with the dialog
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
class _PickerUi:
|
||||
"""A fake splash UI that always selects the first device, then dismisses messages."""
|
||||
|
||||
@@ -1413,6 +1459,58 @@ def test_session_background_is_the_topmost_full_frame_screen() -> None:
|
||||
assert session._float_layers() == [dialog]
|
||||
|
||||
|
||||
def test_floating_text_prompt_is_a_popup_over_a_blank_base() -> None:
|
||||
"""``text(floating=True)`` floats as a centered popup even on an empty stack.
|
||||
|
||||
A mid-flow modal — a remote-admin password, between the node picker and the admin menu —
|
||||
must float like the button dialogs, so a blank base is slipped beneath it rather than
|
||||
letting the prompt fill the frame the way a tool's primary entry screen does.
|
||||
"""
|
||||
session = TuiSession()
|
||||
|
||||
async def main() -> None:
|
||||
task = asyncio.ensure_future(
|
||||
session.text("Password", password=True, floating=True)
|
||||
)
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
if session._has_float():
|
||||
break
|
||||
floats = session._float_layers()
|
||||
assert len(floats) == 1 and isinstance(floats[0], TextScreen)
|
||||
assert session._base_screen() is not floats[0] # a blank base sits beneath it
|
||||
|
||||
floats[0].resolve("hunter2")
|
||||
assert await task == "hunter2"
|
||||
assert session._stack == [] # the blank base is torn down with the prompt
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
def test_default_text_prompt_is_the_full_frame_base() -> None:
|
||||
"""A default ``text`` prompt on an empty stack *is* the frame — a tool's primary entry.
|
||||
|
||||
The Trace target's typed fallback stands in for the select picker, so it fills the frame
|
||||
rather than floating over a blank base (the floating popup is opt-in, see above).
|
||||
"""
|
||||
session = TuiSession()
|
||||
|
||||
async def main() -> None:
|
||||
task = asyncio.ensure_future(session.text("Target node"))
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
if session.top is not None:
|
||||
break
|
||||
assert isinstance(session.top, TextScreen)
|
||||
assert not session._has_float() # no float — the prompt is the background
|
||||
assert session._base_screen() is session.top
|
||||
|
||||
session.top.resolve("YUL")
|
||||
assert await task == "YUL"
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
# --- busy skeleton card ------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user