Dialogs: multi-layer floats, red data-loss tier, unknown-relay hashes
Three independent consistency passes: 1. **Message paths: unknown relays as grey full-hash** The message-paths dialog showed unnamed relay hops as a single lit byte (e.g. `e8`). Now they render at the device's path-hash-mode width, muted grey, and annotated with their addressed byte — `e839f2 (e8)` — so the rows stay readable and cross-reference the route graph's byte labels (matching named hops' trace presentation). Added `hash_as_name` flag to path_text widget. 2. **Dialogs: multi-layer compositor + backdrop for empty stack** Opening a confirm over a floating popup used to stretch that popup to full-frame (the single-float compositor's bug). Now the compositor maintains a pool of _MAX_DIALOG_LAYERS=8 centered-box floats, each rendering one layer above the background — so a popup and its confirm both stay their own size. Also: button_dialog/typed_confirm/autocomplete now float over a blank base when the stack is empty (via _run_dialog_screen), guaranteeing popups render as small boxes on an empty stack, not full-frame chrome. Simplified message_dialog to delegate its backdrop logic to button_dialog. 3. **Data-loss dialogs: red `destructive` tier** Added a second caution tier to the dialog API: danger=True stays amber (disruptive: discard edits, reboot), destructive=True is red (irreversible data loss). Updated: single-record delete, channel "clear slot", courier "clear finished" (new confirm), and startup "remove device" (splash confirm). Per UX spec: bulk/irreversible deletions gate behind typed_confirm (also red); single-record delete is a red Cancel/[verb] button dialog. All changes are backward-compatible. Full suite: 793 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
ed391ab16e
commit
115fe6895d
14 files changed
+332
-76
No files matched your search
@@ -79,8 +79,11 @@ Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now a
|
||||
- Modal popups over the pushed backdrop (never full-screen replacements).
|
||||
- Buttons are reverse-video chips (` Label `, `selected` style): safe way out on the
|
||||
left, the committing verb on the right **and default**, so Enter commits and Esc backs
|
||||
out. Destructive dialogs pass `danger=True`; irreversible ones gate behind
|
||||
`typed_confirm`.
|
||||
out. Two escalating caution tiers theme the frame: `danger=True` (amber) for a
|
||||
disruptive choice — discard edits, reboot, show private key — and `destructive=True`
|
||||
(the reserved red) for irreversible **data loss**, so a delete confirm reads red like
|
||||
its `typed_confirm` sibling. Bulk or irreversible deletions gate behind `typed_confirm`
|
||||
(also red); a single-record delete is a red Cancel/Delete confirm.
|
||||
- Confirms are Cancel/Verb button dialogs, not Yes/No. Prompt lines that ask for input
|
||||
end with a colon.
|
||||
- Dialog `title` is short; the question/instruction goes in `prompt`.
|
||||
|
||||
@@ -161,6 +161,10 @@ class CourierStore:
|
||||
"""How many messages are waiting in the outbox."""
|
||||
return len(self.pending())
|
||||
|
||||
def done_count(self) -> int:
|
||||
"""How many finished (delivered / given-up) entries the history holds."""
|
||||
return sum(1 for m in self._load() if m.status != QUEUED)
|
||||
|
||||
# --- attempt bookkeeping -----------------------------------------------------------
|
||||
|
||||
def note_attempt(self, ident: int, *, when: Optional[datetime] = None) -> None:
|
||||
|
||||
@@ -748,15 +748,16 @@ async def _edit(ctx: "AppContext", device: Device, slot: ChannelSlot) -> bool:
|
||||
async def _clear(ctx: "AppContext", device: Device, slot: ChannelSlot) -> bool:
|
||||
"""Clear a channel slot after confirmation; return whether it was cleared.
|
||||
|
||||
A standard destructive dialog (Cancel left, the verb right and default, danger
|
||||
theming) rather than a bare yes/no, so it reads like every other confirm.
|
||||
A red data-loss dialog (Cancel left, the verb right and default, ``destructive``
|
||||
theming — clearing a slot drops its key) rather than a bare yes/no, so it reads
|
||||
like every other delete confirm.
|
||||
"""
|
||||
choice = await ctx.ui.dialog(
|
||||
f"Clear {slot.name}? This removes the channel from this device.",
|
||||
[("Cancel", None), ("Clear", "clear")],
|
||||
title="Clear channel",
|
||||
default=1,
|
||||
danger=True,
|
||||
destructive=True,
|
||||
)
|
||||
if choice != "clear":
|
||||
return False
|
||||
|
||||
@@ -132,8 +132,17 @@ async def open_courier(ctx: "AppContext") -> Optional[dict[str, Any]]:
|
||||
if choice == _QUEUE:
|
||||
await _queue_flow(ctx, contacts)
|
||||
elif choice == _CLEAR:
|
||||
store.clear_done()
|
||||
cursor = None # the row itself disappears
|
||||
done = store.done_count()
|
||||
if await ctx.ui.dialog(
|
||||
f"Clear {done} finished "
|
||||
f"{'entry' if done == 1 else 'entries'} from the history?",
|
||||
[("Cancel", False), ("Clear", True)],
|
||||
title="Clear finished",
|
||||
default=1,
|
||||
destructive=True,
|
||||
):
|
||||
store.clear_done()
|
||||
cursor = None # the row itself disappears
|
||||
elif isinstance(choice, tuple) and choice[0] == "msg":
|
||||
await _entry_actions(ctx, int(choice[1]))
|
||||
finally:
|
||||
|
||||
@@ -16,7 +16,9 @@ detail read together:
|
||||
many-path graph stays readable.
|
||||
* the **arrival list** beneath is one row per logged copy — time, reception SNR, and
|
||||
the relay chain through the shared compact path widget, each named hop annotated
|
||||
with the hash byte it is addressed by (``YUL-Poly (3d)``, the trace presentation).
|
||||
with the hash byte it is addressed by (``YUL-Poly (3d)``, the trace presentation);
|
||||
an unknown relay stands in its own hash at the device's path-hash width, muted grey
|
||||
and annotated with that byte (``e839f2 (e8)``).
|
||||
↑↓ move the selection (the graph's highlight follows); a row longer than the
|
||||
dialog **scrolls horizontally with ←→**, the whole line shifting under a ``…`` at
|
||||
whichever edge continues, and snaps back the moment the selection moves on.
|
||||
@@ -73,7 +75,8 @@ class MessagePathsScreen(Screen):
|
||||
channel frame) rather than merely by time — the summary line warns
|
||||
when they weren't.
|
||||
resolve: Maps a hop hash to a friendly name when known.
|
||||
prefix_bytes: Path-hash width to light in unnamed hops' hashes.
|
||||
prefix_bytes: The device's path-hash width — how many bytes of an unnamed
|
||||
hop's hash stand in for its (unknown) name.
|
||||
self_name: Our own node's name (the graph's right endpoint, white).
|
||||
summary: The one-line evidence summary shown under the quoted text.
|
||||
source: Display name of the message's origin — the sender parsed from a
|
||||
@@ -194,8 +197,10 @@ class MessagePathsScreen(Screen):
|
||||
"""One arrival, unabridged: time, reception SNR, and its relay path.
|
||||
|
||||
Hops carry the trace presentation at the graph's grain: each named hop is
|
||||
annotated with the hash byte it is addressed by (``YUL-Poly (3d)``), unnamed
|
||||
hops show that byte bare, so the rows and the graph's labels cross-reference.
|
||||
annotated with the hash byte it is addressed by (``YUL-Poly (3d)``); an unnamed
|
||||
hop, having no name to show, stands in its own hash at the device's path-hash
|
||||
width — muted grey, since colour is the "this is a name" signal — annotated with
|
||||
that same byte (``e839f2 (e8)``), so the rows and the graph's labels cross-reference.
|
||||
"""
|
||||
row = Text()
|
||||
row.append(arrival.when.astimezone().strftime("%H:%M:%S"), style="muted")
|
||||
@@ -211,7 +216,7 @@ class MessagePathsScreen(Screen):
|
||||
path_text(
|
||||
arrival.hops, self._resolve,
|
||||
prefix_bytes=self._prefix_bytes, self_name=self._self_name,
|
||||
empty="direct", show_hash=True, hash_bytes=1,
|
||||
empty="direct", show_hash=True, hash_bytes=1, hash_as_name=True,
|
||||
)
|
||||
)
|
||||
if arrival.resend:
|
||||
|
||||
@@ -18,8 +18,9 @@ opened from the main menu. Every successful trace — a *Trace target* boomerang
|
||||
set. The card scrolls (PgUp/PgDn/Home/End) when it outgrows the terminal. From there
|
||||
*Trace this path* reopens Trace path with the record's route prefilled, so a claim
|
||||
worth re-testing is one Enter from the air again;
|
||||
* deletion comes in three grains: one record, one discipline (every width), or
|
||||
everything (the last two behind a confirm — the wholesale one typed).
|
||||
* deletion comes in three grains, each behind a red data-loss dialog: one record
|
||||
behind a Cancel/Delete confirm, and — the bulk grains — one discipline (every width)
|
||||
or everything, each gated behind typing ``delete``.
|
||||
|
||||
Nothing here transmits: it reads the boards the trace tools filled.
|
||||
"""
|
||||
@@ -116,7 +117,7 @@ class RecordDialog(Screen):
|
||||
(PgUp/PgDn/Home/End) when it outgrows the frame, while the arrows drive the actions.
|
||||
Two actions besides Back: *Trace this path* reopens Trace path with the record's route
|
||||
prefilled (propagation shifts; a record is a claim worth re-testing), and *Delete…*
|
||||
removes this one record behind a confirm.
|
||||
removes this one record behind a red Cancel/Delete data-loss confirm.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -635,14 +636,14 @@ async def open_records(ctx: "AppContext") -> dict:
|
||||
if picked in (CANCEL, None, "__back__"):
|
||||
return None
|
||||
category = CATEGORY_BY_ID[str(picked)]
|
||||
sure = await ctx.ui.dialog(
|
||||
f"Delete every {category.title} record, at every hash width?",
|
||||
[("Cancel", False), ("Delete", True)],
|
||||
count = len(ctx.repo.discoveries(category.id))
|
||||
if await session.typed_confirm(
|
||||
f"This deletes all {count} {category.title} "
|
||||
f"record{'s' if count != 1 else ''} — every hash width. "
|
||||
"They can only be re-earned by walking them again.",
|
||||
"delete",
|
||||
title="Delete discipline records",
|
||||
default=1,
|
||||
danger=True,
|
||||
)
|
||||
if sure:
|
||||
):
|
||||
ctx.repo.delete_discoveries(category.id)
|
||||
|
||||
while True:
|
||||
@@ -712,7 +713,7 @@ async def open_records(ctx: "AppContext") -> dict:
|
||||
[("Cancel", False), ("Delete", True)],
|
||||
title="Delete record",
|
||||
default=1,
|
||||
danger=True,
|
||||
destructive=True,
|
||||
)
|
||||
if sure:
|
||||
ctx.repo.delete_discovery(record.id)
|
||||
+13
-6
@@ -231,12 +231,15 @@ class Ui:
|
||||
default: int = 0,
|
||||
keys: Optional[dict[str, Any]] = None,
|
||||
danger: bool = False,
|
||||
destructive: bool = False,
|
||||
) -> Any:
|
||||
"""Show a centered button dialog; return the chosen value or ``None`` on Esc.
|
||||
|
||||
The general choose-one popup (a prompt above a row of buttons). ``danger`` themes
|
||||
the prompt and border in the cautionary style for destructive or disruptive
|
||||
choices. Buttons follow the platform-dialog convention: the safe way out sits on
|
||||
The general choose-one popup (a prompt above a row of buttons). Two escalating
|
||||
cautionary tiers theme the prompt and border: ``danger`` (amber) for a disruptive
|
||||
choice — discarding edits, a reboot — and ``destructive`` (the reserved error red)
|
||||
for irreversible data loss, so a delete confirm reads red like its typed-confirm
|
||||
sibling. Buttons follow the platform-dialog convention: the safe way out sits on
|
||||
the left and the committing action on the right, which is also the sensible
|
||||
``default`` so Enter commits it while Esc always backs out.
|
||||
"""
|
||||
@@ -517,6 +520,7 @@ class PlainUi(Ui):
|
||||
default: int = 0,
|
||||
keys: Optional[dict[str, Any]] = None,
|
||||
danger: bool = False,
|
||||
destructive: bool = False,
|
||||
) -> Any:
|
||||
"""Unsupported in scripted CLI mode."""
|
||||
raise self._no_prompt()
|
||||
@@ -758,16 +762,19 @@ class TuiUi(Ui):
|
||||
default: int = 0,
|
||||
keys: Optional[dict[str, Any]] = None,
|
||||
danger: bool = False,
|
||||
destructive: bool = False,
|
||||
) -> Any:
|
||||
"""Delegate to the session's button dialog, themed cautionary when ``danger``."""
|
||||
"""Delegate to the session's button dialog, themed cautionary when ``danger``,
|
||||
or in the reserved error red when ``destructive`` (irreversible data loss)."""
|
||||
tier = "err" if destructive else "warn" if danger else ""
|
||||
return await self.session.button_dialog(
|
||||
prompt,
|
||||
buttons,
|
||||
title=title,
|
||||
default=default,
|
||||
keys=keys,
|
||||
prompt_style="warn" if danger else "",
|
||||
border_style="warn" if danger else "accent",
|
||||
prompt_style=tier,
|
||||
border_style=tier or "accent",
|
||||
)
|
||||
|
||||
async def typed_confirm(
|
||||
|
||||
+97
-44
@@ -85,6 +85,11 @@ _KEY_ACTIONS: dict[Any, str] = {
|
||||
#: terminals (TODO: thread through ``settings`` and the config editor).
|
||||
_RECLAIM_LAST_COLUMN = os.environ.get("MESHTERM_FULL_WIDTH", "1") != "0"
|
||||
|
||||
#: How many stacked dialog layers the layout can float over the background at once. A fixed
|
||||
#: pool of centered-box floats (see :meth:`TuiSession._build_app`), sized well past the deepest
|
||||
#: real nesting — a tool's list, an item's detail popup, and a confirm over that is only three.
|
||||
_MAX_DIALOG_LAYERS = 8
|
||||
|
||||
|
||||
class _WidthExtendedOutput:
|
||||
"""A prompt_toolkit ``Output`` proxy that reports one extra terminal column.
|
||||
@@ -371,7 +376,8 @@ class TuiSession:
|
||||
— 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
|
||||
cautionary (warn prompt and border), since it only ever gates a removal.
|
||||
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`.
|
||||
|
||||
Args:
|
||||
prompt: The question shown above the buttons.
|
||||
@@ -390,8 +396,8 @@ class TuiSession:
|
||||
title=title,
|
||||
default=1,
|
||||
footer_hint=footer_hint,
|
||||
prompt_style="warn",
|
||||
border_style="warn",
|
||||
prompt_style="err",
|
||||
border_style="err",
|
||||
)
|
||||
screen.chrome = False
|
||||
screen.banner = banner
|
||||
@@ -614,9 +620,27 @@ class TuiSession:
|
||||
button_idle_style=button_idle_style,
|
||||
border_style=border_style,
|
||||
)
|
||||
result = await self.run_screen(screen)
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
backdrop: Optional[ScrollScreen] = None
|
||||
if not self._stack:
|
||||
backdrop = ScrollScreen("", floating=False, footer_hint="")
|
||||
self.push(backdrop)
|
||||
try:
|
||||
return await self.run_screen(screen)
|
||||
finally:
|
||||
if backdrop is not None:
|
||||
self.pop(backdrop)
|
||||
|
||||
async def typed_confirm(
|
||||
self, warning: str, word: str, *, title: str = "Are you sure?"
|
||||
) -> bool:
|
||||
@@ -626,7 +650,9 @@ class TuiSession:
|
||||
collapses its result to a plain bool: ``True`` only when the user typed the word,
|
||||
``False`` when they backed out with Esc.
|
||||
"""
|
||||
result = await self.run_screen(TypedConfirmDialog(warning, word, title=title))
|
||||
result = await self._run_dialog_screen(
|
||||
TypedConfirmDialog(warning, word, title=title)
|
||||
)
|
||||
return result is True
|
||||
|
||||
async def autocomplete(
|
||||
@@ -639,7 +665,7 @@ class TuiSession:
|
||||
validate: Optional[Validator] = None,
|
||||
) -> Optional[str]:
|
||||
"""Show a free-text prompt with suggestions; return text or ``None`` if cancelled."""
|
||||
result = await self.run_screen(
|
||||
result = await self._run_dialog_screen(
|
||||
AutocompleteScreen(
|
||||
title, choices, prompt=prompt, default=default, validate=validate
|
||||
)
|
||||
@@ -653,33 +679,22 @@ class TuiSession:
|
||||
("✓ flood advertisement sent") doesn't warrant a full result window, so it floats
|
||||
as a small dialog over whatever screen is beneath — Enter (OK) or Esc dismisses
|
||||
it. The border takes the message's strongest tone (see :func:`_message_border`),
|
||||
so an error pops red while a success stays in the standard accent.
|
||||
|
||||
When the screen stack is empty — a tool run straight from the main menu, which is
|
||||
popped while the tool executes — a blank base frame is pushed first, because a
|
||||
lone floating screen is otherwise drawn *as* the base (full-frame, no popup); the
|
||||
same trick the reconnect dialog uses.
|
||||
so an error pops red while a success stays in the standard accent. It delegates to
|
||||
:meth:`button_dialog`, which floats it over a blank base when the stack is empty (a
|
||||
tool run straight from the menu, which is popped while the tool executes).
|
||||
|
||||
Args:
|
||||
message: The outcome to show — a pre-styled :class:`Text` (note markup
|
||||
survives into the dialog) or a plain string.
|
||||
title: Optional dialog heading (typically the tool or action name).
|
||||
"""
|
||||
backdrop: Optional[ScrollScreen] = None
|
||||
if not self._stack:
|
||||
backdrop = ScrollScreen("", floating=False, footer_hint="")
|
||||
self.push(backdrop)
|
||||
try:
|
||||
await self.button_dialog(
|
||||
message,
|
||||
[("OK", "ok")],
|
||||
title=title,
|
||||
footer_hint="Enter OK",
|
||||
border_style=_message_border(message),
|
||||
)
|
||||
finally:
|
||||
if backdrop is not None:
|
||||
self.pop(backdrop)
|
||||
await self.button_dialog(
|
||||
message,
|
||||
[("OK", "ok")],
|
||||
title=title,
|
||||
footer_hint="Enter OK",
|
||||
border_style=_message_border(message),
|
||||
)
|
||||
|
||||
async def scroll(
|
||||
self, renderable: RenderableType, *, title: str = "", footer_hint: str = ""
|
||||
@@ -803,10 +818,26 @@ class TuiSession:
|
||||
"""Construct the prompt_toolkit application, layout, and key bindings."""
|
||||
base_control = FormattedTextControl(self._render_base, focusable=True)
|
||||
base_window = Window(base_control, always_hide_cursor=True)
|
||||
float_window = Window(
|
||||
FormattedTextControl(self._render_float), always_hide_cursor=True
|
||||
)
|
||||
# The busy overlay is the last float, so it draws on top of the dialog float — the
|
||||
# One centered-box float per stacked dialog layer, bottom-to-top: each renders the
|
||||
# k-th screen floating above the background (see :meth:`_float_layers`), so a dialog
|
||||
# opened over an existing popup draws *over* it — both at their own size — instead of
|
||||
# the lower one being stretched to fill the frame. A generous fixed pool covers any
|
||||
# realistic nesting; the ConditionalContainer hides the layers not in use this frame.
|
||||
dialog_floats = [
|
||||
Float(
|
||||
ConditionalContainer(
|
||||
Window(
|
||||
FormattedTextControl(
|
||||
lambda i=i: self._render_float_layer(i)
|
||||
),
|
||||
always_hide_cursor=True,
|
||||
),
|
||||
filter=Condition(lambda i=i: len(self._float_layers()) > i),
|
||||
)
|
||||
)
|
||||
for i in range(_MAX_DIALOG_LAYERS)
|
||||
]
|
||||
# The busy overlay is the last float, so it draws on top of every dialog float — the
|
||||
# top of the z-order. It is a content-sized window (dont_extend_*) with no anchors, so
|
||||
# the FloatContainer centres just its skeleton card over the screen rather than blanking it.
|
||||
overlay_window = Window(
|
||||
@@ -818,9 +849,7 @@ class TuiSession:
|
||||
root = FloatContainer(
|
||||
content=base_window,
|
||||
floats=[
|
||||
Float(
|
||||
ConditionalContainer(float_window, filter=Condition(self._has_float))
|
||||
),
|
||||
*dialog_floats,
|
||||
Float(
|
||||
ConditionalContainer(
|
||||
overlay_window, filter=Condition(self._overlay_visible)
|
||||
@@ -876,17 +905,40 @@ class TuiSession:
|
||||
viewport = max(1, rows - header_h - 1 - 2) # minus footer(1) and panel border(2)
|
||||
return cols - 4, viewport
|
||||
|
||||
def _base_index(self) -> int:
|
||||
"""Stack index of the full-frame background screen.
|
||||
|
||||
The background is the top-most *non-floating* screen — a menu, map, or list that
|
||||
fills the frame — and every floating dialog above it is drawn as a centered box
|
||||
over it (see :meth:`_float_layers`). When the whole stack is floating (a tool
|
||||
whose own primary screen is a floating select, e.g. Channels), the bottom screen
|
||||
is the background: it is the one the dialogs above it should float over.
|
||||
"""
|
||||
for i in range(len(self._stack) - 1, -1, -1):
|
||||
if not getattr(self._stack[i], "floating", False):
|
||||
return i
|
||||
return 0
|
||||
|
||||
def _base_screen(self) -> Optional[Screen]:
|
||||
"""The screen drawn as the background (parent of a floating dialog, else the top)."""
|
||||
"""The screen drawn as the full-frame background, or ``None`` when the stack is empty."""
|
||||
if not self._stack:
|
||||
return None
|
||||
if self._has_float() and len(self._stack) >= 2:
|
||||
return self._stack[-2]
|
||||
return self._stack[-1]
|
||||
return self._stack[self._base_index()]
|
||||
|
||||
def _float_layers(self) -> list[Screen]:
|
||||
"""The floating dialogs stacked above the background, bottom-to-top.
|
||||
|
||||
Each is drawn as its own centered box over the ones beneath — so opening a dialog
|
||||
over an existing popup leaves that popup at its own size rather than stretching it
|
||||
to fill the frame (the single-float compositor's old failing).
|
||||
"""
|
||||
if not self._stack:
|
||||
return []
|
||||
return self._stack[self._base_index() + 1:]
|
||||
|
||||
def _has_float(self) -> bool:
|
||||
"""Whether the top screen should be drawn as a centered dialog over the base."""
|
||||
return len(self._stack) >= 2 and bool(getattr(self.top, "floating", False))
|
||||
"""Whether any dialog floats over the background this frame."""
|
||||
return bool(self._float_layers())
|
||||
|
||||
def _render_base(self) -> ANSI:
|
||||
"""Render the persistent frame around the background screen."""
|
||||
@@ -904,12 +956,13 @@ class TuiSession:
|
||||
footer = self.top.footer_hint if self.top else base.footer_hint
|
||||
return ANSI(frame.compose_base(self._header(cols), base, footer, cols, rows))
|
||||
|
||||
def _render_float(self) -> ANSI:
|
||||
"""Render the top screen as a centered dialog (only when floating)."""
|
||||
if not self._has_float() or self.top is None:
|
||||
def _render_float_layer(self, index: int) -> ANSI:
|
||||
"""Render the ``index``-th floating dialog (bottom-to-top) as a centered box."""
|
||||
layers = self._float_layers()
|
||||
if index >= len(layers):
|
||||
return ANSI("")
|
||||
cols, rows = self._size()
|
||||
return ANSI(frame.compose_dialog(self.top, cols, rows))
|
||||
return ANSI(frame.compose_dialog(layers[index], cols, rows))
|
||||
|
||||
def _overlay_visible(self) -> bool:
|
||||
"""Whether the busy overlay should be painted this frame.
|
||||
|
||||
+24
-2
@@ -248,6 +248,7 @@ def path_text(
|
||||
hash_bytes: Optional[int] = None,
|
||||
device_hash: Optional[str] = None,
|
||||
dim_from: Optional[int] = None,
|
||||
hash_as_name: bool = False,
|
||||
) -> Text:
|
||||
"""Render a hop sequence compactly on one line — THE path widget.
|
||||
|
||||
@@ -261,7 +262,12 @@ def path_text(
|
||||
row. The trace-flavoured options: ``show_hash`` annotates each named hop with the
|
||||
hash it is addressed by (``Alice (3d63)``), a ``None`` hop is our own device at a
|
||||
route's endpoints, and ``dim_from`` fades the tail a caller wants read as automatic
|
||||
(a boomerang's mirrored return leg). An empty path reads as ``empty``.
|
||||
(a boomerang's mirrored return leg). ``hash_as_name`` reframes an *unnamed* hop as
|
||||
its own identity — the hash at the path-hash-mode (``prefix_bytes``) width, muted
|
||||
grey with no prefix lit (colour is the "this is a name" signal, and there is no
|
||||
name), annotated with its addressed byte like a named hop (``e839f2 (e8)``) — where
|
||||
the default instead lights the compact addressed hash. An empty path reads as
|
||||
``empty``.
|
||||
|
||||
Args:
|
||||
hops: The hops in propagation order — hex hashes, with ``None`` marking our
|
||||
@@ -279,6 +285,9 @@ def path_text(
|
||||
``show_hash`` is on.
|
||||
dim_from: Render hops at/after this index — and the arrows into them — faint
|
||||
(a planned route's return leg); ``None`` dims nothing.
|
||||
hash_as_name: Present each unnamed hop as its identity hash — muted grey at the
|
||||
``prefix_bytes`` width, annotated with its addressed byte (``hash_bytes``)
|
||||
like a named hop — rather than the compact prefix-lit addressed hash.
|
||||
|
||||
Returns:
|
||||
A one-line :class:`Text`. Space tighter than the path is the caller's call,
|
||||
@@ -297,7 +306,7 @@ def path_text(
|
||||
_path_node(
|
||||
hop, resolve, prefix_bytes=prefix_bytes, self_name=self_name,
|
||||
show_hash=show_hash, hash_bytes=hash_bytes, device_hash=device_hash,
|
||||
dim=dim,
|
||||
dim=dim, hash_as_name=hash_as_name,
|
||||
)
|
||||
)
|
||||
return text
|
||||
@@ -313,6 +322,7 @@ def _path_node(
|
||||
hash_bytes: Optional[int],
|
||||
device_hash: Optional[str],
|
||||
dim: bool,
|
||||
hash_as_name: bool = False,
|
||||
) -> Text:
|
||||
"""One node of :func:`path_text` (see there for the rendering rules)."""
|
||||
note_style = "faint" if dim else "muted"
|
||||
@@ -337,6 +347,18 @@ def _path_node(
|
||||
shown = _shorten_hash(hop, hash_bytes)
|
||||
if dim:
|
||||
return Text(shown, style="faint")
|
||||
if hash_as_name:
|
||||
# No name: the hash is the node's identity. Show it at the path-hash-mode
|
||||
# width, fully muted (prefix_bytes=0 lights nothing — colour is the "this is
|
||||
# a name" signal, and there is no name), then annotate it with the byte it
|
||||
# was addressed by, exactly as a named hop is — unless that byte already *is*
|
||||
# the whole shown hash. So a 3-byte mode reads ``e839f2 (e8)`` and still
|
||||
# cross-references a byte-labelled route graph.
|
||||
identity = _shorten_hash(hop, prefix_bytes or None)
|
||||
text = highlighted_hash(identity, 0)
|
||||
if show_hash and shown and shown != identity:
|
||||
text.append(f" ({shown})", style=note_style)
|
||||
return text
|
||||
return highlighted_hash(shown, prefix_bytes)
|
||||
|
||||
|
||||
|
||||
@@ -376,7 +376,7 @@ class _ScriptedUi:
|
||||
async def text(self, title: str, *, default: str = "", validate=None, help_text: str = "", password: bool = False): # noqa: ANN001, ANN201
|
||||
return self._texts.pop(0)
|
||||
|
||||
async def dialog(self, prompt, buttons, *, title: str = "", default: int = 0, keys=None, danger: bool = False): # noqa: ANN001, ANN201
|
||||
async def dialog(self, prompt, buttons, *, title: str = "", default: int = 0, keys=None, danger: bool = False, destructive: bool = False): # noqa: ANN001, ANN201
|
||||
return self._dialogs.pop(0)
|
||||
|
||||
|
||||
|
||||
@@ -67,6 +67,24 @@ def test_paths_screen_labels_every_relay_with_its_hash_byte() -> None:
|
||||
assert "Waymarker (a1)" in body
|
||||
|
||||
|
||||
def test_paths_screen_shows_unknown_relay_as_grey_mode_width_hash() -> None:
|
||||
"""An unnamed relay stands in its own hash at the device's path-hash width, muted
|
||||
grey and annotated with its addressed byte — not the bare one-byte prefix, lit."""
|
||||
now = utcnow()
|
||||
arrivals = [
|
||||
Arrival(when=now, hops=("3d63",), snr=4.0), # selected: a named relay
|
||||
Arrival(when=now + timedelta(seconds=2), hops=("e839f2ab",), snr=-2.0),
|
||||
]
|
||||
screen = _screen(arrivals, prefix_bytes=3) # 3-byte routing → e839f2
|
||||
lines = screen.render_body(76)
|
||||
row = next(ln for ln in lines if "e839f2" in _plain([ln]))
|
||||
assert "e839f2 (e8)" in _plain([row]) # mode-width identity, then the byte
|
||||
assert "38;2;148;163;184" in row # muted grey over the hash
|
||||
assert "38;2;94;234;212" not in row # never the brand highlight
|
||||
graph = _plain(lines).split("origin →")[0]
|
||||
assert "e8" in graph # the row's byte still cross-references the graph label
|
||||
|
||||
|
||||
def test_paths_screen_colours_selected_path_by_snr_over_gray() -> None:
|
||||
"""The selected path's edges take its arrival's SNR hue; the unused path stays gray."""
|
||||
screen = _screen(_arrivals()) # selected arrival's SNR is +4.0 dB → the "ok" band
|
||||
|
||||
@@ -129,6 +129,39 @@ async def test_message_dialog_floats_over_a_blank_backdrop_end_to_end() -> None:
|
||||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||||
|
||||
|
||||
async def test_confirm_floats_over_an_existing_popup_end_to_end() -> None:
|
||||
"""A confirm opened over a floating detail popup renders and resolves — the render
|
||||
loop runs the multi-layer float pool for real (base + detail + confirm), the case
|
||||
the old single-float compositor stretched the detail to full-frame.
|
||||
"""
|
||||
from prompt_toolkit.input.defaults import create_pipe_input
|
||||
from prompt_toolkit.output import DummyOutput
|
||||
|
||||
from meshterm.ui.tui.screen import ScrollScreen
|
||||
from meshterm.ui.tui.select import Choice, SelectScreen
|
||||
|
||||
with create_pipe_input() as inp:
|
||||
session = TuiSession(input=inp, output=DummyOutput())
|
||||
|
||||
async def main() -> None:
|
||||
base = ScrollScreen(Text("channels"), title="Channels") # full-frame background
|
||||
detail = SelectScreen("Ops", [Choice("Clear", "clr")]) # a floating popup
|
||||
session.push(base)
|
||||
session.push(detail)
|
||||
# Two layers float over the one background while the confirm is up.
|
||||
assert session._base_screen() is base
|
||||
inp.send_text("\r") # Enter commits the confirm's default (Delete)
|
||||
confirmed = await session.button_dialog(
|
||||
"Clear Ops?", [("Cancel", 0), ("Clear", 1)], default=1, border_style="err"
|
||||
)
|
||||
assert confirmed == 1
|
||||
assert session._float_layers() == [detail] # confirm gone, detail still afloat
|
||||
session.pop(detail)
|
||||
session.pop(base)
|
||||
|
||||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||||
|
||||
|
||||
# -- the message dialog's border tone -----------------------------------------------
|
||||
|
||||
|
||||
@@ -143,3 +176,46 @@ def test_message_border_echoes_the_strongest_tone() -> None:
|
||||
assert _message_border(ok) == "accent"
|
||||
assert _message_border(both) == "err"
|
||||
assert _message_border("plain string") == "accent"
|
||||
|
||||
|
||||
# -- the button dialog's caution tiers ----------------------------------------------
|
||||
|
||||
|
||||
class _ButtonRecordingSession:
|
||||
"""Records the styling kwargs :meth:`TuiUi.dialog` hands the button dialog."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.kwargs: dict[str, Any] = {}
|
||||
|
||||
async def button_dialog(self, prompt: str, buttons: list, **kwargs: Any) -> Any:
|
||||
self.kwargs = kwargs
|
||||
return buttons[-1][1]
|
||||
|
||||
|
||||
async def _dialog_styles(**kw: Any) -> dict[str, Any]:
|
||||
session = _ButtonRecordingSession()
|
||||
ui = TuiUi(session) # type: ignore[arg-type]
|
||||
await ui.dialog("Delete this record?", [("Cancel", False), ("Delete", True)], **kw)
|
||||
return session.kwargs
|
||||
|
||||
|
||||
async def test_dialog_destructive_tier_borders_red() -> None:
|
||||
"""A data-loss dialog draws in the reserved error red — prompt and border."""
|
||||
styles = await _dialog_styles(destructive=True)
|
||||
assert styles["border_style"] == "err"
|
||||
assert styles["prompt_style"] == "err"
|
||||
|
||||
|
||||
async def test_dialog_danger_tier_stays_amber() -> None:
|
||||
"""A merely-disruptive dialog keeps the amber caution tone, never the deletion red."""
|
||||
styles = await _dialog_styles(danger=True)
|
||||
assert styles["border_style"] == "warn"
|
||||
assert styles["prompt_style"] == "warn"
|
||||
|
||||
|
||||
async def test_dialog_plain_is_neutral_and_destructive_outranks_danger() -> None:
|
||||
"""No flag is the neutral accent frame; destructive wins over danger when both are set."""
|
||||
plain = await _dialog_styles()
|
||||
assert plain["border_style"] == "accent" and plain["prompt_style"] == ""
|
||||
both = await _dialog_styles(danger=True, destructive=True)
|
||||
assert both["border_style"] == "err" and both["prompt_style"] == "err"
|
||||
@@ -1377,6 +1377,42 @@ def test_session_stack_and_float_selection() -> None:
|
||||
assert not session._has_float()
|
||||
|
||||
|
||||
def test_session_stacks_every_dialog_over_one_full_frame_background() -> None:
|
||||
"""A dialog over a dialog: both float over the single background, neither full-frame.
|
||||
|
||||
The old compositor drew only the top float and rendered the second-from-top as the
|
||||
full-frame base — so opening a confirm over a popup stretched that popup to fill the
|
||||
frame. Now the background is the deepest full-frame screen and every floating layer
|
||||
above it stays its own centered box.
|
||||
"""
|
||||
session = TuiSession()
|
||||
channels = SelectScreen("Channels", [Choice("Ops", 1)]) # a tool's own floating list
|
||||
detail = SelectScreen("Ops (private)", [Choice("Clear", "clr")]) # its item popup
|
||||
confirm = ButtonDialog("Clear Ops?", [("Cancel", 0), ("Clear", 1)], border_style="err")
|
||||
for screen in (channels, detail, confirm):
|
||||
session.push(screen)
|
||||
|
||||
# The bottom (all-floating) screen is the one full-frame background; the detail popup
|
||||
# and the confirm both float over it — the detail is no longer promoted to the base.
|
||||
assert session._base_screen() is channels
|
||||
assert session._float_layers() == [detail, confirm]
|
||||
|
||||
session.pop(confirm)
|
||||
assert session._float_layers() == [detail] # detail stays a float, not full-frame
|
||||
assert session._base_screen() is channels
|
||||
|
||||
|
||||
def test_session_background_is_the_topmost_full_frame_screen() -> None:
|
||||
"""A non-floating screen (a map, a scroll window) is the background under any dialogs."""
|
||||
session = TuiSession()
|
||||
full = ScrollScreen(Text("map"), title="map") # floating=False
|
||||
dialog = ButtonDialog("go?", [("No", 0), ("Yes", 1)])
|
||||
session.push(full)
|
||||
session.push(dialog)
|
||||
assert session._base_screen() is full
|
||||
assert session._float_layers() == [dialog]
|
||||
|
||||
|
||||
# --- busy skeleton card ------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -60,6 +60,27 @@ def test_path_text_lights_the_hash_prefix() -> None:
|
||||
assert "77" in brand # the addressed prefix stands out within the hash
|
||||
|
||||
|
||||
def test_path_text_hash_as_name_shows_grey_identity_hash() -> None:
|
||||
"""An unnamed hop stands in its own hash at the mode width, muted, plus its byte."""
|
||||
text = path_text(
|
||||
["e839f2ab"], _resolve, prefix_bytes=3,
|
||||
show_hash=True, hash_bytes=1, hash_as_name=True,
|
||||
)
|
||||
assert text.plain == "e839f2 (e8)" # mode width identity, then the addressed byte
|
||||
styles = {text.plain[s.start : s.end]: str(s.style) for s in text.spans}
|
||||
assert styles.get("e839f2") == "muted" # greyed — colour is the "this is a name" cue
|
||||
assert not any(str(s.style) == "brand" for s in text.spans) # no prefix lit
|
||||
|
||||
|
||||
def test_path_text_hash_as_name_drops_the_byte_when_it_is_the_whole_hash() -> None:
|
||||
"""When the mode width is one byte, the identity already is the byte — no ``(e8)``."""
|
||||
text = path_text(
|
||||
["e839f2ab"], _resolve, prefix_bytes=1,
|
||||
show_hash=True, hash_bytes=1, hash_as_name=True,
|
||||
)
|
||||
assert text.plain == "e8"
|
||||
|
||||
|
||||
def test_path_text_trace_flavour_annotates_hashes_and_brackets_us() -> None:
|
||||
"""show_hash appends the addressed hash to names; None hops are our device."""
|
||||
text = path_text(
|
||||
|
||||
Reference in new issue
Block a user