Files
MeshTerm/meshterm/ui/path_composer.py
T
Jean-Pierre MartineauandClaude Fable 5 94f565e61f Windowed lists everywhere the chrome should hold still
The atlas's list-in-a-fixed-screen pattern graduates to a shared
ListWindow (tui.screen): fit() settles the window, the marker rows, and
the highlight clamp together, and remembers the PgUp/PgDn stride; the
frame now records the viewport (or a dialog's budget) before render_body
runs so screens can size their chrome to it. Adopters: the dashboard
pins its charts and windows the feed; the trace screen pins route,
aggregates, and actions over a windowed results block; the path
composer pins the route preview over windowed suggestions (and gains
PgUp/PgDn); the nodes table pins its title, column header, and legend
around windowed rows. The composer's route preview and node rows also
join THE path widget, dropping its hand-rolled Name (hash) renderer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 17:23:41 -04:00

470 lines
22 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 trace path composer: build a forced route hop by hop, guided by observed links.
A floating dialog over a live trace screen. The route under construction reads across
the top — ``us → hop → … → us`` — and beneath it sits a suggestion list: the nodes the
topology evidence says the path's current tail can hear, strongest observed link first
(see :meth:`~meshterm.services.topology.MeshTopology.next_hops`). Every link is
bidirectional evidence, so a path that was ever *received* through two nodes proposes
that link in either direction.
The composer serves both trace features, and which one owns it decides how the route's
return half is written (the trace protocol has no separate return-path field — the spec
sent to the device is one walk that ends within our earshot):
* **Target mode** (*Trace target* — a ``target_id`` is pinned): only the outbound leg
is composed. The target stays pinned as the turning point and the return is the
outbound hops mirrored — the preview resolves and dims that half, since it isn't
yours to choose. An *Auto* action hands routing back to the device.
* **Path mode** (*Trace path* — no target at all): the whole walk is yours, hop by
hop, until the route comes back within earshot of us — only the final landing on
our own node is filled in (and dimmed). The preview opens as just ``us → us``.
There is no *Auto* action, because with no destination there is nothing for the
device to route to.
Interaction, following the reorder screen's cursor-over-rows-and-actions pattern:
* ↑/↓ move over suggestions and the action rows; Enter on a suggestion appends it.
* Typing filters the suggestions by name or hash — and when the typed text is itself
even-length hex, an *add custom hop* row appears, so a node we have never observed
(or a bare hash from another tool) can be forced into the route.
* Backspace erases the filter first; with the filter empty it removes the last hop.
* When the path's tail is a repeater we hold admin credentials for, a *fetch
neighbours* row asks that repeater over the mesh for its own neighbour table —
fresh second-vantage evidence exactly where composing ran out of it. The dialog
resolves :class:`FetchNeighbours` and the owning flow fetches, refreshes the
topology, and reopens the composer mid-thought (hops preserved).
* Enter on **Use this path** commits the composed spec; Esc cancels with no change.
Nodes and the route preview render through the shared path widget
(:func:`~meshterm.ui.widgets.path_text`, trace flavour) — ``Name (hash)`` with names
in their app-wide hues and our own node the white ``you`` — so a node reads the same
wherever it appears. The suggestion list scrolls in a window under the pinned route
preview (faint ``↑/↓ n more`` markers at its edges; PgUp/PgDn stride by a windowful),
so the route under construction never leaves the screen.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from rich.cells import cell_len
from rich.text import Text
from ..services.topology import MeshTopology, _is_hex, render_custom_spec, render_forced_spec
from .theme import snr_style
from .tui.render import render_lines, render_to_ansi
from .tui.screen import ListWindow, Screen
from .widgets import _age_seconds, _format_age, path_text
#: Sentinel spec meaning "no forced path — let the device route" (the trace screen's
#: empty-spec convention). Only meaningful in target mode; a path walk has no target
#: for the device to route to, so path mode never resolves it.
AUTO_SPEC = ""
#: How many suggestions to show at most; beyond this the evidence is too weak to matter
#: and the dialog would outgrow the screen.
_MAX_SUGGESTIONS = 12
#: Action-row sentinels (kept distinct from suggestion rows, which carry node ids).
_USE = "use"
_AUTO = "auto"
_CANCEL = "cancel"
#: One-letter tags for the evidence classes backing a link, shown beside each
#: suggestion: T(race), R(oute — the firmware's learned out_path), P(acket log),
#: N(eighbour table fetched from a repeater).
_SOURCE_TAGS = {"trace": "T", "route": "R", "packet": "P", "neighbour": "N"}
@dataclass(frozen=True, slots=True)
class FetchNeighbours:
"""The composer's resolution when the user asks a repeater for its neighbours.
The dialog itself never touches the radio; it resolves this marker and the owning
flow performs the login + fetch, refreshes the topology, and reopens the composer
with :attr:`PathComposerScreen.hops` re-seeded.
Attributes:
node: Canonical id of the repeater to query (the path's tail when committed).
"""
node: str
class PathComposerScreen(Screen):
"""Compose a forced trace path step by step, mirrored at a target or hand-routed.
Resolves with the finished spec (comma-separated hex — in target mode the outbound
hops, the target, then the mirrored return; in path mode the composed walk
verbatim), :data:`AUTO_SPEC` for device routing (target mode only), or
:data:`~meshterm.ui.tui.screen.CANCEL`.
"""
footer_hint = "↑↓ move · Enter add · type to filter · ⌫ remove · Esc cancel"
def __init__(
self,
*,
device_label: str,
topology: MeshTopology,
width_bytes: int,
target_id: Optional[str] = None,
target_hash: Optional[str] = None,
target_label: Optional[str] = None,
device_hash: Optional[str] = None,
hops: Optional[list[str]] = None,
fetch_nodes: frozenset[str] = frozenset(),
) -> None:
"""Build the composer.
A pinned target (all three ``target_*`` arguments) selects target mode: the
target is excluded from suggestions (the path implicitly turns at it) and the
emitted spec appends it plus the mirrored return. With no target the composer
is in path mode — the whole walk is composed by hand and committed verbatim.
Args:
device_label: Our own node's name, opening and closing the route preview.
topology: The evidence graph suggestions are drawn from.
width_bytes: Preferred per-hop path-hash width (bytes) for the emitted spec.
target_id: The pinned target's canonical id, or ``None`` for path mode.
target_hash: The target's hex hash used as the symmetric spec's turning
point (its full key prefix; truncated to the spec width at commit).
target_label: The target's display name for the route preview.
device_hash: Our own public key, so the preview's endpoints carry a hash
exactly like the trace screen's route line.
hops: Canonical ids of already-composed hops (reopening the dialog resumes
where the user left off).
fetch_nodes: Canonical ids whose live neighbour table can be fetched
(repeater contacts with a public key); when the path's tail is one of
them, the *fetch neighbours* row appears.
"""
super().__init__()
self.title = f"Compose path — {target_label}" if target_label else "Compose path"
self._target_id = target_id
self._target_hash = (target_hash or "").lower().removeprefix("0x")
self._target_label = target_label
self._device_label = device_label
self._device_hash = (device_hash or "").lower().removeprefix("0x")
self._topology = topology
self._width_bytes = width_bytes
self._hops: list[str] = list(hops or [])
self._fetch_nodes = fetch_nodes
self._entry = ""
self._index = 0
#: The suggestion/action list's window under the pinned route preview.
self._list = ListWindow()
@property
def hops(self) -> list[str]:
"""The composed hops so far (for re-seeding after a fetch)."""
return list(self._hops)
# --- state -----------------------------------------------------------------
@property
def _mirrored(self) -> bool:
"""Whether the return leg is the pinned target's mirror (target mode)."""
return self._target_id is not None
def _tail(self) -> str:
"""The node the path currently ends at (us until a hop is added)."""
return self._hops[-1] if self._hops else self._topology.self_id
def _suggestions(self) -> list:
"""The current suggestion rows, filtered by the typed entry.
Target mode excludes the target (the path implicitly turns at it) and every
used hop (revisiting one on the outbound leg is never useful — the mirror
already recrosses it). Path mode only excludes ourselves and the tail: a
return leg legitimately reuses outbound repeaters.
Returns:
The (possibly filtered) :class:`~meshterm.services.topology.HopSuggestion`
list for the path's tail, capped at :data:`_MAX_SUGGESTIONS`.
"""
if self._mirrored:
exclude = frozenset({self._topology.self_id, self._target_id, *self._hops})
else:
exclude = frozenset({self._topology.self_id, self._tail()})
suggestions = self._topology.next_hops(self._tail(), exclude=exclude)
needle = self._entry.lower()
if needle:
suggestions = [
s
for s in suggestions
if s.node.startswith(needle)
or needle in (self._topology.display_name(s.node) or "").lower()
]
return suggestions[:_MAX_SUGGESTIONS]
def _custom_hex(self) -> Optional[str]:
"""The typed entry as an addable hex hop, or ``None`` when it isn't one."""
needle = self._entry.lower().removeprefix("0x")
return needle if _is_hex(needle) else None
def _rows(self) -> list[tuple[str, object]]:
"""The cursor-addressable rows: custom hop, suggestions, fetch, then actions."""
rows: list[tuple[str, object]] = []
custom = self._custom_hex()
if custom:
rows.append(("custom", custom))
rows.extend(("hop", s) for s in self._suggestions())
# Standing on a repeater we hold credentials for, its live neighbour table is
# one keypress away — placed with the suggestions, because that is what it
# extends: "don't see the node you need? ask the repeater what it hears."
if self._tail() in self._fetch_nodes:
rows.append(("fetch", self._tail()))
rows.append(("action", _USE))
if self._mirrored: # a path walk has no target for the device to route to
rows.append(("action", _AUTO))
rows.append(("action", _CANCEL))
return rows
def _spec(self) -> str:
"""Render the composed route as the spec the trace command will send.
Target mode appends the target and the mirrored return leg (see
:func:`~meshterm.services.topology.render_forced_spec`); path mode renders
the composed walk verbatim (:func:`~meshterm.services.topology.
render_custom_spec`) — empty until it has at least one hop.
"""
if self._mirrored:
return render_forced_spec(
tuple(self._hops), self._target_hash, self._width_bytes
)
return render_custom_spec(tuple(self._hops), self._width_bytes)
# --- rendering ---------------------------------------------------------------
def _path_entry(self, node: str) -> Optional[str]:
"""A canonical id as a :func:`path_text` hop entry (``None`` = our device).
The pinned target rides as its full hash when one is known, so its hash
annotation shows the spec width even past the canonical id's 12 hex.
"""
if node == self._topology.self_id:
return None
if node == self._target_id:
return self._target_hash or node
return node
def _resolve_entry(self, entry: str) -> str:
"""Resolve a preview entry to its display name (the target by its label)."""
if self._target_id is not None and entry == (self._target_hash or self._target_id):
return self._target_label or entry
return self._topology.display_name(entry) or entry
def _node_text(self, node: str, *, dim: bool = False) -> Text:
"""One route node through THE path widget, the trace presentation.
``Name (hash)`` when known — the name in its app-wide hue, our own device
the white ``you`` — a bare brand hash when not. Hashes show at the spec's
preferred path-hash width, the same width every other trace-feature view
truncates to, so a node reads the same length everywhere.
Args:
node: The node's canonical id (or ``self_id`` for our own device).
dim: Whether to render in the resolved-return-leg's uniform faint style
rather than the normal route colours.
"""
return path_text(
[self._path_entry(node)], self._resolve_entry,
prefix_bytes=self._width_bytes, self_name=self._device_label,
show_hash=True, hash_bytes=self._width_bytes,
device_hash=self._device_hash or None,
dim_from=0 if dim else None,
)
def _route_preview(self) -> Text:
"""The route under construction, endpoints filled in automatically.
One :func:`path_text` in the trace flavour: target mode shows the composed
outbound in full colour, then the pinned target and the mirrored return
resolved and dimmed right alongside it (``dim_from``) — the dimming reads
as "this half isn't yours to compose". Path mode shows every composed hop
in full colour (they are all yours) between our own node at both ends,
with only the final landing back on us dimmed.
"""
entries: list[Optional[str]] = [None]
entries.extend(self._path_entry(hop) for hop in self._hops)
if self._mirrored:
entries.append(self._path_entry(self._target_id))
entries.extend(self._path_entry(hop) for hop in reversed(self._hops))
entries.append(None)
return path_text(
entries, self._resolve_entry,
prefix_bytes=self._width_bytes, self_name=self._device_label,
show_hash=True, hash_bytes=self._width_bytes,
device_hash=self._device_hash or None,
dim_from=(2 if self._mirrored else 1) + len(self._hops),
)
def _suggestion_text(self, suggestion) -> Text: # noqa: ANN001
"""One suggestion row: node, then its link's evidence trail."""
text = self._node_text(suggestion.node)
link = suggestion.link
snr = link.median_snr
if snr is not None:
text.append(" ↔ ", style="muted")
text.append(f"{snr:+.1f} dB", style=snr_style(snr))
text.append(f" {link.samples}×", style="muted")
age = _format_age(_age_seconds(link.last_seen))
text.append(f" · {age}", style="muted")
tags = "".join(_SOURCE_TAGS[s] for s in sorted(link.sources & _SOURCE_TAGS.keys()))
if tags:
text.append(f" · {tags}", style="faint")
return text
def _row_text(self, kind: str, payload: object) -> Text:
"""The display text for one cursor-addressable row."""
if kind == "custom":
text = Text("+ add hop ", style="warn")
text.append(str(payload), style="brand")
text.append(" (typed hex)", style="muted")
return text
if kind == "hop":
return self._suggestion_text(payload)
if kind == "fetch":
name = self._topology.display_name(str(payload)) or str(payload)[:12]
text = Text("⇣ Fetch neighbours from ", style="")
text.append(name, style="brand")
text.append(" (asks the repeater over the mesh)", style="muted")
return text
if payload == _USE:
label = Text.assemble(("✓ ", "ok"), "Use this path")
spec = self._spec()
# A path walk with no hops yet has no spec to commit.
label.append(f" ({spec})" if spec else " (add a hop first)", style="muted")
return label
if payload == _AUTO:
return Text("Auto — let the device route")
return Text.assemble(("✗ ", "err"), "Cancel")
@property
def dialog_width(self) -> int:
"""Natural outer width hugging the widest row (compositor still caps it)."""
widths = [cell_len(self.title), cell_len(self.footer_hint)]
widths.append(cell_len(self._route_preview().plain))
for kind, payload in self._rows():
widths.append(cell_len(self._row_text(kind, payload).plain) + 2)
return max(widths, default=20) + 8
def render_body(self, width: int) -> list[str]:
"""Render the pinned route preview and filter line, then the windowed rows.
The preview and heading hold still; the suggestion/action rows scroll in
whatever the dialog's budget has left (see :class:`ListWindow`), so a long
suggestion list can never push the route being composed out of the dialog.
"""
rows = self._rows()
self._index = max(0, min(self._index, len(rows) - 1))
lines = render_lines(self._route_preview(), width)
lines.append("")
if self._entry:
lines.append(render_to_ansi(Text(f"/{self._entry}", style="warn"), width))
else:
heading = Text("Next hop from ", style="muted")
heading.append_text(self._node_text(self._tail()))
heading.append(" — strongest first", style="muted")
lines.extend(render_lines(heading, width))
# Each windowable entry is one rendered line tagged with its row index
# (``None`` = a spacer or note line the cursor can't land on).
entries: list[tuple[Optional[int], str]] = []
for i, (kind, payload) in enumerate(rows):
if kind == "action" and (i == 0 or rows[i - 1][0] != "action"):
entries.append((None, "")) # a spacer sets the action group apart
is_sel = i == self._index
text = Text("❯ " if is_sel else " ", style="brand" if is_sel else "")
text.append_text(self._row_text(kind, payload))
if is_sel:
text.style = "brand"
text.no_wrap = True
text.truncate(width, overflow="ellipsis")
entries.append((i, render_to_ansi(text, width)))
if not any(kind == "hop" for kind, _ in rows):
if any(kind == "fetch" for kind, _ in rows):
note = "(no observed links from here — fetch the repeater's neighbours, or type a hex hash)"
else:
note = "(no observed links from here — type a hex hash to force a hop)"
entries.append((None, render_to_ansi(Text(note, style="muted"), width)))
win = max(3, self._scroll_viewport - len(lines))
at = next(p for p, (row, _line) in enumerate(entries) if row == self._index)
top, count = self._list.fit(len(entries), win, at)
self._cursor = None
if top > 0:
lines.append(render_to_ansi(ListWindow.marker(top, "above"), width))
for pos in range(top, top + count):
row, line = entries[pos]
if row == self._index:
self._cursor = len(lines)
lines.append(line)
below = len(entries) - top - count
if below > 0:
lines.append(render_to_ansi(ListWindow.marker(below, "below"), width))
self._scroll_total = max(1, len(lines))
return lines
def cursor_line(self) -> Optional[int]:
"""The body line of the highlighted row, so the session keeps it visible."""
return getattr(self, "_cursor", None)
# --- input ---------------------------------------------------------------------
def _commit_row(self) -> None:
"""Apply the highlighted row: append a hop or run an action."""
rows = self._rows()
if not rows:
return
kind, payload = rows[self._index]
if kind == "custom":
self._hops.append(str(payload))
self._entry = ""
self._index = 0
elif kind == "hop":
self._hops.append(payload.node) # type: ignore[union-attr]
self._entry = ""
self._index = 0
elif kind == "fetch":
self.resolve(FetchNeighbours(node=str(payload)))
elif payload == _USE:
spec = self._spec()
if spec: # an empty path walk would masquerade as AUTO_SPEC
self.resolve(spec)
elif payload == _AUTO:
self.resolve(AUTO_SPEC)
else:
super().handle("escape")
def handle(self, action: str, data: str = "") -> None:
"""Move the cursor, edit the entry, add/remove hops, or commit/cancel."""
rows = self._rows()
if action == "up" and rows:
self._index = (self._index - 1) % len(rows)
elif action == "down" and rows:
self._index = (self._index + 1) % len(rows)
elif action == "pageup" and rows:
self._index = max(0, self._index - self._list.page)
elif action == "pagedown" and rows:
self._index = min(len(rows) - 1, self._index + self._list.page)
elif action in ("home", "ctrl_home"):
self._index = 0
elif action in ("end", "ctrl_end"):
self._index = max(0, len(rows) - 1)
elif action == "enter":
self._commit_row()
elif action == "backspace":
if self._entry:
self._entry = self._entry[:-1]
elif self._hops:
self._hops.pop()
self._index = 0
elif action == "text" and data.isprintable() and data not in ("/",):
self._entry += data
self._index = 0
elif action == "escape":
super().handle("escape")