diff --git a/CLAUDE.md b/CLAUDE.md index 4338130..ce516d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/meshterm/core/courier_store.py b/meshterm/core/courier_store.py index a2df106..243a15d 100644 --- a/meshterm/core/courier_store.py +++ b/meshterm/core/courier_store.py @@ -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: diff --git a/meshterm/ui/channels.py b/meshterm/ui/channels.py index cb21381..32a7153 100644 --- a/meshterm/ui/channels.py +++ b/meshterm/ui/channels.py @@ -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 diff --git a/meshterm/ui/courier_screen.py b/meshterm/ui/courier_screen.py index ad49a98..6f0d3f1 100644 --- a/meshterm/ui/courier_screen.py +++ b/meshterm/ui/courier_screen.py @@ -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: diff --git a/meshterm/ui/message_paths_screen.py b/meshterm/ui/message_paths_screen.py index fb9cad9..e2454c2 100644 --- a/meshterm/ui/message_paths_screen.py +++ b/meshterm/ui/message_paths_screen.py @@ -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: diff --git a/meshterm/ui/records_screen.py b/meshterm/ui/records_screen.py index c4cc070..7e4eb6d 100644 --- a/meshterm/ui/records_screen.py +++ b/meshterm/ui/records_screen.py @@ -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) diff --git a/meshterm/ui/surface.py b/meshterm/ui/surface.py index 5a1e922..793e621 100644 --- a/meshterm/ui/surface.py +++ b/meshterm/ui/surface.py @@ -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( diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 00e75cc..316ce69 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -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. diff --git a/meshterm/ui/widgets.py b/meshterm/ui/widgets.py index 9ad7a76..0fa827b 100644 --- a/meshterm/ui/widgets.py +++ b/meshterm/ui/widgets.py @@ -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) diff --git a/tests/test_channels.py b/tests/test_channels.py index 304dd48..5a755f9 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -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) diff --git a/tests/test_message_paths_screen.py b/tests/test_message_paths_screen.py index 4ad6dd0..85f4bcc 100644 --- a/tests/test_message_paths_screen.py +++ b/tests/test_message_paths_screen.py @@ -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 diff --git a/tests/test_surface.py b/tests/test_surface.py index 3e43e12..99a2606 100644 --- a/tests/test_surface.py +++ b/tests/test_surface.py @@ -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" diff --git a/tests/test_tui.py b/tests/test_tui.py index 119605d..d36c296 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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 ------------------------------------------------------ diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 80f3753..0644e45 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -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(