Files
MeshTerm/meshterm/ui/message_paths_screen.py
T
Jean-Pierre MartineauandClaude Opus 4.8 115fe6895d 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>
2026-07-15 23:30:58 -04:00

291 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The Message paths dialog: every way one chat message reached this radio.
The interactive face of :mod:`~meshterm.services.message_paths`, opened from the chat
with ``^P`` (or Enter on a picked direct message). The evidence — one logged frame per
arrival, each with the relay path it rode — is laid out twice, so the shape and the
detail read together:
* a **graph** up top draws every distinct path the message took through the shared
route-graph widget (:mod:`~meshterm.ui.pathgraph`): the currently selected path
coloured by its reception SNR (the SNR palette), the unused paths gray beneath it.
The origin sits at the left, we sit at the right, and every relay in between gets its
own node-type marker (``▲`` repeater, …) plus the first byte of its hash — set straight
above or below the marker, in the mesh name's own colour, so the byte reads as that node
and never crowds the line running through it. A node-type key sits under the fan. The row
list carries the full names, so the graph's labels stay two cells wide and a
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);
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.
Nothing here transmits; like the service beneath it, this is a read-model over what
the radio already heard.
"""
from __future__ import annotations
from typing import Optional
from rich.text import Text
from ..core.models import ChatMessage
from ..services.message_paths import Arrival
from .pathgraph import PathLayer, render_path_graph
from .theme import snr_rgb, snr_style
from .tui.render import crop_cells, render_to_ansi
from .tui.screen import Screen
from .widgets import NodeResolver, TypeOf, node_type_legend, path_text, route_graph_style
#: Cells one ←/→ press shifts the selected row by.
_HSTEP = 4
#: Edge colours: the selected path draws in its arrival's SNR hue (white when it carried no
#: reading) over the unused paths' gray.
_EDGE_SELECTED = (255, 255, 255)
_EDGE_UNUSED = (110, 110, 110)
class MessagePathsScreen(Screen):
"""A floating dialog: one message's arrivals as a path graph over a row list."""
def __init__(
self,
message: ChatMessage,
arrivals: list[Arrival],
*,
matched: bool,
resolve: NodeResolver,
prefix_bytes: int,
self_name: Optional[str],
summary: str,
source: Optional[str] = None,
type_of: Optional[TypeOf] = None,
) -> None:
"""Build the dialog over one message's matched arrivals.
Args:
message: The chat message whose delivery evidence is shown.
arrivals: Its logged arrivals, oldest first (may be empty).
matched: Whether the arrivals were matched by content (a decrypted
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: 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
channel message, us for an outbound one, the peer for a direct chat
(``None`` reads as an unknown ``?`` origin).
type_of: Maps a relay hash to its node type, so the graph marks a repeater
``▲`` (etc.) instead of a generic dot; ``None`` keeps the plain dots.
"""
super().__init__()
self.title = "Message paths"
self._message = message
self._arrivals = arrivals
self._matched = matched
self._resolve = resolve
self._prefix_bytes = prefix_bytes
self._self_name = self_name
self._summary = summary
self._source = source
self._type_of = type_of
self._index = 0
#: Cells the selected row is shifted left by (reset whenever ↑↓ move), and
#: how far it *can* shift, measured against the width of the last render.
self._hshift = 0
self._hmax = 0
self._cursor: Optional[int] = None
# --- input ---------------------------------------------------------------
@property
def footer_hint(self) -> str: # type: ignore[override]
"""Row keys while there are rows; a bare close hint otherwise."""
if self._arrivals:
return "↑↓ move · ←→ scroll line · Esc close"
return "Esc close"
def handle(self, action: str, data: str = "") -> None:
"""Move the selection, shift the selected line sideways, or dismiss."""
rows = len(self._arrivals)
if action == "up" and rows:
self._index = (self._index - 1) % rows
self._hshift = 0
elif action == "down" and rows:
self._index = (self._index + 1) % rows
self._hshift = 0
elif action == "pageup" and rows:
self._index = max(0, self._index - self._page_step)
self._hshift = 0
elif action in ("pagedown", "space") and rows:
self._index = min(rows - 1, self._index + self._page_step)
self._hshift = 0
elif action in ("home", "ctrl_home") and rows:
self._index = 0
self._hshift = 0
elif action in ("end", "ctrl_end") and rows:
self._index = rows - 1
self._hshift = 0
elif action == "left":
self._hshift = max(0, self._hshift - _HSTEP)
elif action == "right":
self._hshift = min(self._hmax, self._hshift + _HSTEP)
elif action in ("escape", "enter"):
self.resolve(None)
def cursor_line(self) -> Optional[int]:
"""The selected row, so a tall arrival list scrolls with the selection."""
return self._cursor
# --- rendering -------------------------------------------------------------
def render_body(self, width: int) -> list[str]:
"""The quoted message and summary, the path graph, then the arrival rows."""
quoted = self._message.text.replace("\n", " ")
if len(quoted) > 64:
quoted = quoted[:63] + "…"
stamp = Text(self._message.created_at.astimezone().strftime("%b %d %H:%M"),
style="muted")
stamp.append(" · ", style="muted")
stamp.append(self._summary, style="muted" if self._matched else "warn")
lines = [
render_to_ansi(Text(f"“{quoted}”"), width, no_wrap=True),
render_to_ansi(stamp, width, no_wrap=True),
]
self._cursor = None
if not self._arrivals:
lines.append("")
note = (
"Nothing overheard — the radio only logs frames it hears while "
"MeshTerm is listening."
if self._matched
else "No direct-message frames logged in the window."
)
lines.append(render_to_ansi(Text(note, style="muted"), width))
self._scroll_total = len(lines)
return lines
lines.append("")
lines.extend(self._graph_lines(width))
caption = Text("origin → you · colour = SNR of selected · labels = hash byte",
style="faint")
lines.append(render_to_ansi(caption, width, no_wrap=True))
lines.append(render_to_ansi(node_type_legend(), width, no_wrap=True))
lines.append("")
for i, arrival in enumerate(self._arrivals):
if i == self._index:
self._cursor = len(lines)
lines.append(self._selected_line(arrival, width))
else:
row = Text(" ")
row.append_text(self._row_text(arrival))
lines.append(render_to_ansi(row, width, no_wrap=True))
self._scroll_total = len(lines)
return lines
# -- the rows --
def _row_text(self, arrival: Arrival) -> Text:
"""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)``); 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")
row.append(" ")
if arrival.snr is not None:
row.append(f"{arrival.snr:+5.1f} dB", style=snr_style(arrival.snr))
else:
row.append(" " * 8)
row.append(" ")
if arrival.hops:
row.append("via ", style="muted")
row.append_text(
path_text(
arrival.hops, self._resolve,
prefix_bytes=self._prefix_bytes, self_name=self._self_name,
empty="direct", show_hash=True, hash_bytes=1, hash_as_name=True,
)
)
if arrival.resend:
row.append(f" (resend #{arrival.resend})", style="muted")
return row
def _selected_line(self, arrival: Arrival, width: int) -> str:
"""The highlighted row: ``❯`` pointer, shifted by ``←→`` under edge ``…``.
The whole line scrolls — timestamp and SNR included — and the shift bound
is measured here against the current width, so a resize can only ever leave
the row clamped back into range.
"""
full = self._row_text(arrival)
avail = max(1, width - 2)
self._hmax = max(0, full.cell_len - avail)
self._hshift = min(self._hshift, self._hmax)
shift = self._hshift
left_more = 1 if shift > 0 else 0
right_more = 1 if shift + avail < full.cell_len else 0
inner = max(1, avail - left_more - right_more)
line = Text("❯ ", style="brand", no_wrap=True)
if left_more:
line.append("…", style="muted")
line.append_text(crop_cells(full, shift + left_more, inner))
if right_more:
line.append("…", style="muted")
line.style = "brand"
return render_to_ansi(line, width, no_wrap=True)
# -- the graph --
def _paths(self) -> list[tuple[str, ...]]:
"""The distinct relay paths across the arrivals, in first-heard order."""
seen: list[tuple[str, ...]] = []
for arrival in self._arrivals:
if arrival.hops not in seen:
seen.append(arrival.hops)
return seen
def _graph_lines(self, width: int) -> list[str]:
"""Draw every distinct path origin → us, the selected one in its SNR hue over gray.
The shared route-graph widget does the layout (lanes fanned from the centre
in first-heard order, shared relays averaged together); this just maps each
distinct path to a :class:`~meshterm.ui.pathgraph.PathLayer` — the selected
one on top coloured by the selected arrival's reception SNR (the SNR palette,
white when it carried no reading), the rest gray beneath — and hands it the shared
route-graph callbacks (:func:`~meshterm.ui.widgets.route_graph_style`): endpoints
named, relays their own map marker where the type is known, each labelled by its
first hash byte in the node's own hue.
"""
arrival = self._arrivals[self._index]
selected = arrival.hops
selected_color = snr_rgb(arrival.snr) if arrival.snr is not None else _EDGE_SELECTED
layers = [
PathLayer(
hops=path,
color=selected_color if path == selected else _EDGE_UNUSED,
priority=3 if path == selected else 2,
)
for path in self._paths()
]
glyph_of, label_of, label_rgb_of = route_graph_style(
resolve=self._resolve, self_name=self._self_name, source=self._source,
type_of=self._type_of,
)
return render_path_graph(
layers, width,
glyph_of=glyph_of, label_of=label_of, label_rgb_of=label_rgb_of,
)