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:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-15 23:30:58 -04:00
1 parent ed391ab16e
commit 115fe6895d
14 files changed
+332 -76

No files matched your search

+5 -2
View File
@@ -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`.
+4
View File
@@ -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:
+4 -3
View File
@@ -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
+11 -2
View File
@@ -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:
+10 -5
View File
@@ -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:
+12 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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)
+18
View File
@@ -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
+76
View File
@@ -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"
+36
View File
@@ -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 ------------------------------------------------------
+21
View File
@@ -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(