`ruff format .` across the repo: 153 files, no behaviour change. This is the second gate CONTRIBUTING.md promises, and until now it reported 154 files needing reformat — so a first contributor ran the command the guide told them to run and could not tell their own diff from the background. Landed on its own, with nothing else in it, because a whole-repo reformat is only safe to skip past in blame if it is provably nothing but layout. It was checked that way rather than assumed: every changed file was formatted in memory and compared by full `ast.dump()`, and 152 of 153 come out structurally identical (the one exception was a docstring opening on a quote character, fixed in the commit before this one). The string and comment token streams were diffed separately, so a quote flip or an edited comment could not hide inside a re-layout — five quote-style normalisations and sixteen implicit-concatenation joins, all value-identical, and one commented-out line gaining the space after its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite is 1888 green, gallery gate and `test_theme16` included. What actually changed, in order of how much of the diff it is: wrapped calls and signatures rejoin onto one line where they fit inside the hundred columns; the magic trailing comma expands what it is there to expand; complex slices gain their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are laid out to be read were fenced in the previous commit and are untouched here. The aligned trailing-comment column is gone from about ninety lanes that were not fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced where it sits. Every comment stays on the entry it describes, so nothing there reads wrong; it just no longer lines up. Add this commit to `.git-blame-ignore-revs`, which follows immediately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
754 lines
37 KiB
Python
754 lines
37 KiB
Python
"""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 — ``★ → hop → … → ★`` — 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 ``★ → + → ★``.
|
||
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 inserts it
|
||
at the route's insertion cursor.
|
||
* ←/→ slide that insertion cursor along the editable leg — it rides *in* the route as
|
||
a hop of its own, the ``cursor``-white ``+`` slot the next chosen node drops into — so a
|
||
hop can be spliced in (at the slot) or removed (⌫, to its left) anywhere in the
|
||
route, not just at the end. It opens at the end of the composed leg, where inserting
|
||
is appending — the classic flow unchanged. The suggestion list follows: it always
|
||
proposes next hops from the node left of the cursor.
|
||
* 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 hop left
|
||
of the cursor.
|
||
* When the cursor stands after 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 and cursor preserved).
|
||
* Enter on **Use this path** commits the composed spec; Esc cancels with no change.
|
||
* A walk that crosses some link twice in the same direction shows a yellow ⚠ note
|
||
under the preview: still walkable, but no longer a *trail*, so the trophy case
|
||
will ignore it (the no-cheat rule — see :mod:`~meshterm.services.records`).
|
||
|
||
Nodes render through the shared path widget in the trace flavour — ``Name (hash)``
|
||
with names in their app-wide hues, the hash at the session's chosen path-hash width,
|
||
and a hop the topology can't name falling back to the owning screen's own resolver
|
||
(see :meth:`PathComposerScreen._resolve_entry`), so a node reads the same here as in
|
||
the trace window this dialog floats over. The route preview is that same trace-window
|
||
route lane, live: :mod:`~meshterm.ui.pathline` chips (or arrows), wrapped at hop
|
||
boundaries, our two ends the bare ``★``, and no hash repeated after a name. That preview
|
||
*is* the path, so the *Use this path* row doesn't respell it: the row names the action and
|
||
the picture above it names the route. 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 rich.cells import cell_len
|
||
from rich.text import Text
|
||
|
||
from ..services.records import first_repeated_edge
|
||
from ..services.topology import (
|
||
HopSuggestion,
|
||
Link,
|
||
MeshTopology,
|
||
_is_hex,
|
||
render_custom_spec,
|
||
render_forced_spec,
|
||
)
|
||
from .pathline import PathLine, cut_to, path_line
|
||
from .theme import snr_style
|
||
from .tui.render import query_line, render_lines, render_to_ansi
|
||
from .tui.screen import ListWindow, Screen
|
||
from .widgets import NodeResolver, _age_seconds, _format_age, _identity, 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).
|
||
#: There is no cancel sentinel: leaving is Esc's, and a row that only pressed Esc for
|
||
#: you cost a cursor stop above the row that commits.
|
||
_USE = "use"
|
||
_AUTO = "auto"
|
||
|
||
#: 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"}
|
||
|
||
#: The inline no-cheat warning, shown under the route preview while the composed walk
|
||
#: crosses some link twice in the same direction — repeating a stretch would let its
|
||
#: score be farmed, so the trophy case disqualifies such a walk (it is no longer a
|
||
#: *trail*, graph theory's walk-with-no-repeated-edge; see
|
||
#: :func:`~meshterm.services.records.first_repeated_edge`). Composing one stays legal —
|
||
#: the warning only says the walk can't set records.
|
||
_NOT_A_TRAIL = "⚠ repeats a link — not a trail, so records ignore this walk"
|
||
|
||
|
||
@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` and :attr:`PathComposerScreen.cursor`
|
||
re-seeded.
|
||
|
||
Attributes:
|
||
node: Canonical id of the repeater to query (the insertion cursor's anchor
|
||
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`.
|
||
|
||
The dialog only ever grows (see :attr:`~meshterm.ui.tui.screen.Screen.grow_only`):
|
||
every added hop widens the route preview and each new tail swaps in a different
|
||
suggestion set, so a size-to-content box would twitch on every keystroke. Instead
|
||
the box holds its tallest and widest extent for the composing session, like the
|
||
packet viewer.
|
||
"""
|
||
|
||
grow_only = True
|
||
|
||
#: The composing keys, in the hint grammar's navigation-then-actions-then-Esc order.
|
||
#: Esc's verb is the only part that moves — see :attr:`footer_hint`.
|
||
#: ``←→ slot``, not ``←→ cursor``: this app's *cursor* is the row the ❯ points at,
|
||
#: which is what ↑↓ moves here — the arrows move the **insertion slot** along the path
|
||
#: being built, the app's own word for it (and the one thing besides the row cursor
|
||
#: that wears the selection white).
|
||
_HINT = "↑↓ move · ←→ slot · Enter add · type to filter · ⌫ remove · Esc cancel"
|
||
|
||
@property
|
||
def footer_hint(self) -> str: # type: ignore[override]
|
||
"""The composing keys, with ``Esc clear`` standing in while an entry is typed.
|
||
|
||
Esc peels the typed entry before it abandons the composer, exactly as ⌫ peels it
|
||
before it removes a hop, so the line says which of the two the press will do (the
|
||
app-wide filter rule — the map, the mesh walk and every select list read the same).
|
||
"""
|
||
if not self._entry:
|
||
return self._HINT
|
||
return self._HINT.replace("Esc cancel", "Esc clear")
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
device_label: str,
|
||
topology: MeshTopology,
|
||
width_bytes: int,
|
||
target_id: str | None = None,
|
||
target_hash: str | None = None,
|
||
target_label: str | None = None,
|
||
device_hash: str | None = None,
|
||
hops: list[str] | None = None,
|
||
cursor: int | None = None,
|
||
fetch_nodes: frozenset[str] = frozenset(),
|
||
resolve: NodeResolver = _identity,
|
||
) -> 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).
|
||
cursor: Arrow index the insertion cursor resumes at (reopening after a
|
||
fetch); ``None`` parks it at the path's end, the append position.
|
||
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.
|
||
resolve: The owning screen's node resolver — the fallback that names a hop
|
||
the topology can't (see :meth:`_resolve_entry`), so the composer reads
|
||
the same names the screen that opened it does.
|
||
"""
|
||
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._resolve = resolve
|
||
#: Resolved display names by node id — see :meth:`_resolve_entry`.
|
||
self._names: dict[str, str] = {}
|
||
#: The graph ids each node reads as — see :meth:`_aliases`.
|
||
self._alias_cache: dict[str, set[str]] = {}
|
||
self._width_bytes = width_bytes
|
||
self._hops: list[str] = list(hops or [])
|
||
#: The insertion cursor: which joining arrow of the editable leg it sits on.
|
||
#: Arrow k stands between display node k (us at 0, else hop k) and its
|
||
#: successor — an insert lands at hops index k, ⌫ removes hop k-1.
|
||
self._cursor = len(self._hops) if cursor is None else max(0, min(cursor, len(self._hops)))
|
||
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)
|
||
|
||
@property
|
||
def cursor(self) -> int:
|
||
"""The insertion cursor's arrow index (for re-seeding after a fetch)."""
|
||
return self._cursor
|
||
|
||
# --- 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 _anchor(self) -> str:
|
||
"""The node just left of the insertion cursor (us while the cursor is home).
|
||
|
||
Suggestions, the fetch row, and the *Next hop from* heading all follow this
|
||
anchor — an insert continues the walk from here, wherever the cursor stands.
|
||
"""
|
||
return self._hops[self._cursor - 1] if self._cursor else self._topology.self_id
|
||
|
||
def _same_node(self, a: str, b: str) -> bool:
|
||
"""Whether two graph ids read as one node — same name, one prefixing the other.
|
||
|
||
The evidence graph keeps a hop hash it can't pin down as its own vertex: a
|
||
1-byte ``3d`` that prefix-matches two contacts stays ``3d``, separate from the
|
||
``3d63c6429436`` its wider sightings landed on. Both are real ids with real
|
||
evidence, and the graph is right not to guess — but on screen, once the name
|
||
resolver has named them (see :meth:`_resolve_entry`), they are one row shown
|
||
twice. The test is deliberately both halves: a shared *name*, and one hash a
|
||
prefix of the other. Two unnamed hashes never merge (they resolve to
|
||
themselves, so the names differ) and two same-named nodes on unrelated keys —
|
||
a duplicate contact name, a renamed node — never merge either.
|
||
|
||
Args:
|
||
a: One node id.
|
||
b: The other.
|
||
|
||
Returns:
|
||
Whether the two should be shown, and addressed, as a single node.
|
||
"""
|
||
if a == b:
|
||
return True
|
||
if not (a.startswith(b) or b.startswith(a)):
|
||
return False
|
||
named = self._resolve_entry(a)
|
||
return named != a and named == self._resolve_entry(b)
|
||
|
||
def _aliases(self, node: str) -> set[str]:
|
||
"""Every id in the graph that reads as ``node`` — the stubs it is split across.
|
||
|
||
The other half of :meth:`_same_node`: a merged suggestion must also *stand* as
|
||
the merged node, so the next step out of it sees everything all its ids have
|
||
been heard talking to. Without this, folding ``3d`` into ``3d63c6429436`` would
|
||
quietly cost the composer whatever was only ever observed under the stub.
|
||
|
||
Cached per node, since this walks the whole graph and :meth:`_suggestions` runs
|
||
several times per keystroke (the row list, the dialog's width, the paint). The
|
||
topology is fixed for the screen's lifetime — a neighbour fetch builds a fresh
|
||
one — so the answer can't go stale under the cache.
|
||
"""
|
||
ids = self._alias_cache.get(node)
|
||
if ids is not None:
|
||
return ids
|
||
ids = {node}
|
||
for link in self._topology.links():
|
||
for end in (link.a, link.b):
|
||
if end not in ids and self._same_node(end, node):
|
||
ids.add(end)
|
||
self._alias_cache[node] = ids
|
||
return ids
|
||
|
||
def _pooled(self, one, other): # noqa: ANN001, ANN201
|
||
"""Fold two suggestions for the same node into one row, evidence pooled.
|
||
|
||
The surviving id is the longer of the two — the more specific identity, and the
|
||
one that addresses the node best when the spec is rendered. The readings behind
|
||
it are summed exactly as the topology pools evidence when it coalesces a stub
|
||
itself, so the row's ``n×`` and median SNR describe the whole node rather than
|
||
whichever half happened to rank first.
|
||
"""
|
||
node = one.node if len(one.node) >= len(other.node) else other.node
|
||
stamps = [link.last_seen for link in (one.link, other.link) if link.last_seen is not None]
|
||
ends = sorted((self._anchor(), node))
|
||
return HopSuggestion(
|
||
node=node,
|
||
link=Link(
|
||
a=ends[0],
|
||
b=ends[1],
|
||
samples=one.link.samples + other.link.samples,
|
||
snrs=[*one.link.snrs, *other.link.snrs],
|
||
last_seen=max(stamps) if stamps else None,
|
||
sources=one.link.sources | other.link.sources,
|
||
),
|
||
strength=max(one.strength, other.strength),
|
||
)
|
||
|
||
def _suggestions(self) -> list:
|
||
"""The current suggestion rows — duplicates merged, 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 cursor's
|
||
two neighbours (either would make the inserted hop a self-loop): a return
|
||
leg legitimately reuses outbound repeaters. Every exclusion covers the excluded
|
||
node's aliases too, so a stub of the anchor can't be proposed as a step off it.
|
||
|
||
Suggestions are gathered from all of the anchor's aliases and then folded
|
||
pairwise (:meth:`_same_node`, :meth:`_pooled`), so a node the graph holds under
|
||
both a stub and a full id appears once, with all of its evidence, ranked by its
|
||
best link.
|
||
|
||
Returns:
|
||
The (possibly filtered) :class:`~meshterm.services.topology.HopSuggestion`
|
||
list for the insertion cursor's anchor, capped at :data:`_MAX_SUGGESTIONS`.
|
||
"""
|
||
if self._mirrored:
|
||
exclude = frozenset({self._topology.self_id, self._target_id, *self._hops})
|
||
else:
|
||
after = self._hops[self._cursor] if self._cursor < len(self._hops) else None
|
||
exclude = frozenset(
|
||
{self._topology.self_id, self._anchor()} | ({after} if after else set())
|
||
)
|
||
merged: list = []
|
||
for tail in sorted(self._aliases(self._anchor()), key=len, reverse=True):
|
||
for suggestion in self._topology.next_hops(tail, exclude=exclude):
|
||
if any(self._same_node(suggestion.node, other) for other in exclude):
|
||
continue
|
||
at = next(
|
||
(i for i, m in enumerate(merged) if self._same_node(m.node, suggestion.node)),
|
||
None,
|
||
)
|
||
if at is None:
|
||
merged.append(suggestion)
|
||
else:
|
||
merged[at] = self._pooled(merged[at], suggestion)
|
||
merged.sort(key=lambda s: (-s.strength, s.node))
|
||
needle = self._entry.strip().lower()
|
||
if needle:
|
||
merged = [
|
||
s
|
||
for s in merged
|
||
if s.node.startswith(needle) or needle in self._resolve_entry(s.node).lower()
|
||
]
|
||
return merged[:_MAX_SUGGESTIONS]
|
||
|
||
def _custom_hex(self) -> str | None:
|
||
"""The typed entry as an addable hex hop, or ``None`` when it isn't one."""
|
||
needle = self._entry.strip().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())
|
||
# The cursor standing after 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."
|
||
if self._anchor() in self._fetch_nodes:
|
||
rows.append(("fetch", self._anchor()))
|
||
rows.append(("action", _USE))
|
||
if self._mirrored: # a path walk has no target for the device to route to
|
||
rows.append(("action", _AUTO))
|
||
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)
|
||
|
||
def _walk_nodes(self) -> list[str]:
|
||
"""The whole walk the current composition transmits, as canonical ids.
|
||
|
||
Target mode is the symmetric boomerang (outbound hops, the target, the
|
||
mirrored return); path mode is the composed hops verbatim. This is what the
|
||
record-eligibility check runs over — the mirror halves matter, because a
|
||
repeated outbound stretch repeats on the return leg too.
|
||
"""
|
||
if self._mirrored:
|
||
return [*self._hops, str(self._target_id), *reversed(self._hops)]
|
||
return list(self._hops)
|
||
|
||
# --- rendering ---------------------------------------------------------------
|
||
|
||
def _path_entry(self, node: str) -> str | None:
|
||
"""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 an entry to its display name — the target's label, else a node's.
|
||
|
||
Two resolvers, in order, because they answer different questions. The topology
|
||
names a node it has *identified*: a hop hash that prefix-matches exactly one
|
||
contact is that contact, so its canonical id carries the name. But a short hash
|
||
— a 1-byte hop, the common case at the protocol's default width — often matches
|
||
several contacts, and rather than guess, the topology keeps it as itself and
|
||
knows no name for it. The owning screen's resolver
|
||
(:func:`~meshterm.services.trace_runner.make_node_resolver`) is the same
|
||
first-match lookup the trace screens name their walked hops by, so falling back
|
||
to it means a node the trace window shows as *Lakeside* is *Lakeside* here too,
|
||
instead of a bare ``3d`` the reader has to decode. Nothing is invented: an
|
||
unmatched hash comes back unchanged and renders as a hash.
|
||
|
||
Memoized: both resolvers scan the contact list, and this now runs over every
|
||
link in the graph on the way to each repaint (see :meth:`_aliases`). Neither
|
||
the topology nor the resolver changes while a composer is open — a neighbour
|
||
fetch builds a fresh screen — so the answers are stable for its lifetime.
|
||
"""
|
||
if self._target_id is not None and entry == (self._target_hash or self._target_id):
|
||
return self._target_label or entry
|
||
named = self._names.get(entry)
|
||
if named is None:
|
||
named = self._topology.display_name(entry) or self._resolve(entry) or entry
|
||
self._names[entry] = named
|
||
return named
|
||
|
||
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) -> PathLine:
|
||
"""The route under construction, endpoints filled in automatically.
|
||
|
||
THE path widget, drawn exactly as the trace screen that opened this dialog
|
||
draws its route lane — the composer is a live preview of that lane, so the two
|
||
must be the same picture. 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.
|
||
|
||
Both our ends go bare (``bare_self``): a composed walk always leaves us and
|
||
comes home to us, so the ``★`` says it in one cell and leaves the rest of the
|
||
dialog's width to the hops being chosen. Named hops show no hash either: the hex
|
||
the spec goes on the air as is not what anyone is choosing here — it would only
|
||
crowd the route this dialog exists to shape, which is why the *Use this path* row
|
||
no longer respells it either. An unnamed hop still reads as its hash, truncated
|
||
to the session's chosen path-hash width.
|
||
|
||
The insertion cursor is *in* the route, not between two of its hops: the
|
||
``+`` slot (:data:`~meshterm.ui.pathline.CURSOR_GLYPH`) sits where the next
|
||
chosen node will, in the ``cursor`` white the row cursor below it wears. Drawing an
|
||
insertion point as a position rather than as a gap is what lets the preview stay
|
||
the picture the trace window paints — chips and all, rather than falling back to
|
||
arrows for the sake of a seam to sit in — and lets a wrapped route carry the
|
||
cursor like any other hop instead of stranding it on a line break.
|
||
"""
|
||
entries: list[str | None] = [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_line(
|
||
entries,
|
||
self._resolve_entry,
|
||
prefix_bytes=self._width_bytes,
|
||
self_name=self._device_label,
|
||
hash_bytes=self._width_bytes,
|
||
device_hash=self._device_hash or None,
|
||
dim_from=(2 if self._mirrored else 1) + len(self._hops),
|
||
bare_self=True,
|
||
# The entries open on our own ★, so arrow index k — the slot after display
|
||
# node k — is rendered-hop index k + 1.
|
||
cursor=self._cursor + 1,
|
||
)
|
||
|
||
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":
|
||
text = Text("⇣ Fetch neighbours from ", style="")
|
||
text.append_text(self._node_text(str(payload))) # named like every other row
|
||
text.append(" (asks the repeater over the mesh)", style="muted")
|
||
return text
|
||
if payload == _USE:
|
||
label = Text.assemble(("✓ ", "ok"), "Use this path")
|
||
# "This path" is the one pinned two rows up, in colour and at full width — the
|
||
# dialog exists to shape it and never lets it scroll away. Respelling it here
|
||
# as raw hex (JP, 2026-08-10) said the same thing worse, and grew the row by a
|
||
# hop every time the route did. Only the empty case still needs words: an
|
||
# unarmed row has to say why.
|
||
if not self._spec():
|
||
label.append(" (add a hop first)", style="muted")
|
||
return label
|
||
return Text("Auto — let the device route")
|
||
|
||
@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)]
|
||
# The preview carries its own cursor slot, so the measured line is the drawn
|
||
# line — same hops, same mode, same width.
|
||
widths.append(self._route_preview().text().cell_len)
|
||
if first_repeated_edge(self._walk_nodes()) is not None:
|
||
widths.append(cell_len(_NOT_A_TRAIL))
|
||
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))
|
||
|
||
# The preview breaks at hop boundaries under a 2-column hanging indent (never
|
||
# mid-name, never mid-chip); the insertion slot is one of those hops, so it
|
||
# lands on a line like the rest and is always visible.
|
||
lines = [
|
||
render_to_ansi(line, width, no_wrap=True)
|
||
for line in self._route_preview().wrapped(width, indent=2)
|
||
]
|
||
if first_repeated_edge(self._walk_nodes()) is not None:
|
||
lines.extend(render_lines(Text(_NOT_A_TRAIL, style="warn"), width))
|
||
lines.append("")
|
||
if self._entry:
|
||
lines.append(query_line(self._entry, width))
|
||
else:
|
||
heading = Text("Next hop from ", style="muted")
|
||
heading.append_text(self._node_text(self._anchor()))
|
||
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[int | None, 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="cursor" if is_sel else "")
|
||
text.append_text(self._row_text(kind, payload))
|
||
if is_sel:
|
||
text.style = "cursor"
|
||
text.no_wrap = True
|
||
# A suggestion row names a node in the route's own vocabulary, so it is cut
|
||
# like one: a chip that runs off the dialog cracks, prose keeps the ellipsis.
|
||
text = cut_to(text, width)
|
||
text.no_wrap = True
|
||
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)"
|
||
# One entry per wrapped line: a multi-line string in a single entry would
|
||
# smuggle a newline into the windowed list's row math (and past the width
|
||
# contract, which measures per line — at 53 columns the note wraps).
|
||
for note_line in render_lines(Text(note, style="muted"), width):
|
||
entries.append((None, note_line))
|
||
|
||
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_row = 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_row = 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) -> int | None:
|
||
"""The body line of the highlighted row, so the session keeps it visible."""
|
||
return getattr(self, "_cursor_row", None)
|
||
|
||
# --- input ---------------------------------------------------------------------
|
||
|
||
def _insert_hop(self, node: str) -> None:
|
||
"""Splice a hop in at the insertion cursor, which advances past it."""
|
||
self._hops.insert(self._cursor, node)
|
||
self._cursor += 1
|
||
self._entry = ""
|
||
self._index = 0
|
||
|
||
def _commit_row(self) -> None:
|
||
"""Apply the highlighted row: insert a hop at the cursor or run an action."""
|
||
rows = self._rows()
|
||
if not rows:
|
||
return
|
||
kind, payload = rows[self._index]
|
||
if kind == "custom":
|
||
self._insert_hop(str(payload))
|
||
elif kind == "hop":
|
||
self._insert_hop(payload.node) # type: ignore[union-attr]
|
||
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)
|
||
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
"""Move the cursors, edit the entry, add/remove hops, or commit/cancel."""
|
||
rows = self._rows()
|
||
# Both ends clamp rather than wrap: this list scrolls inside a window (see
|
||
# ListWindow), and a highlight that jumped from the tail to the head would take the
|
||
# whole window with it — the one move that looks like the screen changed under you.
|
||
if action == "up" and rows:
|
||
self._index = max(0, self._index - 1)
|
||
elif action == "down" and rows:
|
||
self._index = min(len(rows) - 1, self._index + 1)
|
||
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 == "left" and self._cursor:
|
||
self._cursor -= 1 # the anchor moved: suggestions re-seat below
|
||
self._index = 0
|
||
elif action == "right" and self._cursor < len(self._hops):
|
||
self._cursor += 1
|
||
self._index = 0
|
||
elif action == "enter":
|
||
self._commit_row()
|
||
elif action == "backspace":
|
||
if self._entry:
|
||
self._entry = self._entry[:-1]
|
||
elif self._cursor:
|
||
self._hops.pop(self._cursor - 1)
|
||
self._cursor -= 1
|
||
self._index = 0
|
||
elif action == "text" and data.isprintable():
|
||
if not data.isspace() or self._entry: # never begin the entry with a space
|
||
self._entry += data
|
||
self._index = 0
|
||
elif action == "escape":
|
||
# The typed entry is the most recent thing the reader entered, so Esc peels it
|
||
# first — the same layering ⌫ already has here (entry, then a hop) and the
|
||
# app-wide rule for a find-as-you-type screen.
|
||
if self._entry:
|
||
self._entry = ""
|
||
self._index = 0
|
||
else:
|
||
super().handle("escape")
|