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>
291 lines
13 KiB
Python
291 lines
13 KiB
Python
"""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,
|
||
)
|