The title is the screen's name now, with no status atoms at all. Everything it used to carry is already on the screen below it: the breadcrumb trail ends on the focus, the line under that names the node in colour with its hash and its ring, and how big the neighbourhood is, is what the canvas and the link list are for. Set once at construction rather than composed per render, so the top row holds still while the walk moves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1449 lines
72 KiB
Python
1449 lines
72 KiB
Python
"""The Mesh walk: walk the mesh's observed shape one node at a time.
|
||
|
||
The interactive face of the ``walk`` tool. The trace path composer already distills
|
||
every fragment of topology we ever received — trace walks, firmware routes, RX-logged
|
||
relay chains, repeater neighbour tables — into one evidence graph
|
||
(:mod:`~meshterm.services.topology`); this screen is that graph made explorable. Nothing
|
||
here transmits: the walk is a reading of what the radio has already heard.
|
||
|
||
Rather than plotting the whole mesh at once (which reads as a hairball the moment the
|
||
graph grows), the walk keeps one node *in focus* — our own, to begin with — and shows
|
||
only its immediate neighbourhood:
|
||
|
||
* the **canvas** — the majority of the screen, so the shape stays legible — anchors the
|
||
focus at the far **west** with its name to its right, then fans a deliberately sparse
|
||
spread of its strongest neighbours across the width to the east, each named just to the
|
||
right of its marker. Every marker is the app-wide **node-type mark**, glyph and colour
|
||
both — the very mark the map and the route graph pin that node with — while *identity*
|
||
rides the name beside it, in the node's key hue. Edges are braille lines coloured by the
|
||
link's median SNR (green → amber → red, slate for links with no reading) and faded by
|
||
evidence age; they leave the *right end of the focus name* so a connecting line never
|
||
crosses a label. The fan sits on a row grid — one marker per row, a blank row between
|
||
each, a blank row above and below where the canvas can afford it — and only as many
|
||
neighbours as those rows carry are drawn; the weaker rest, ranked by observed strength
|
||
(SNR, sample count, and recency folded together, so a strong-but-long-stale link is not
|
||
promoted over a fresher one), collapse into one ``…`` marker, which stands in for
|
||
whichever of them the list highlights (its own mark, its own name, and a grey
|
||
``(2/17)`` for where it sits among them). Only ways *onward* are drawn: the node the
|
||
walk came from is behind you, and ⌫ is how you go back to it.
|
||
* the **link list** beneath names every way onward as a selectable row, strongest
|
||
observed link first — so the cursor's home, row 0, is always the strongest link out of
|
||
here: type glyph, name, hash, SNR with a quality bar, the evidence behind the link
|
||
(samples, sources, age), and how many links continue onward from that node. The highlighted row's marker and label light white on the canvas. The
|
||
list scrolls *within* the screen — the canvas, legend, and heading hold still, and
|
||
faint ``↑/↓ n more`` markers bracket the window — with ↑↓ moving one row and
|
||
PgUp/PgDn a windowful.
|
||
|
||
**Enter walks**: the highlighted neighbour becomes the new focus, the breadcrumb trail
|
||
across the top grows (a :mod:`~meshterm.ui.pathline` path line — powerline chips where the
|
||
terminal draws them, ``you › YUL-Cartierville › …`` where it doesn't, each name in its
|
||
node's own hue), and **⌫ steps back** along it. Walking to a node already on the trail
|
||
truncates the stack to its first appearance — the loop you walked to get back there is
|
||
dropped rather than recorded. The trail never wraps: at rest its *head* goes behind a
|
||
leading ``⋯`` and the rest snaps flush right, so the focus and the steps just taken stay
|
||
in view, and **←→ scroll it** a hop at a time to read back over a long walk (a trailing
|
||
``⋯`` then marks the focus as the part out of view). **^U** refocuses our own node.
|
||
**Typing finds** — a global filter over every node in the graph, islands included — and
|
||
narrows the canvas's fan to matching neighbours as it goes, the focus and the came-from
|
||
node holding through it; Enter teleports the focus to the highlighted match (the trail
|
||
restarts there, since the walk didn't cross the gap). Esc peels find first, the screen
|
||
second.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from collections import deque
|
||
from datetime import datetime
|
||
from typing import TYPE_CHECKING, Optional
|
||
|
||
from rich.cells import cell_len
|
||
from rich.text import Text
|
||
|
||
from ..platforms import get_platform
|
||
from ..core.models import NODE_TYPE_REPEATER, Contact, utcnow
|
||
from ..services.topology import Link, MeshTopology
|
||
from .map_render import _SELF, _UNKNOWN
|
||
from .mapcanvas import RGB, MapCanvas, parse_hex
|
||
from .menus import fit_cells
|
||
from .pathline import ELIDE_HEAD, SELF_GLYPH, PathHop, PathLine, elision_hop
|
||
from .theme import mark_rgb, name_style, snr_style
|
||
from .trace_screen import snr_bar
|
||
from .tui.render import query_line, render_to_ansi
|
||
from .tui.screen import ListWindow, Screen
|
||
from .widgets import _DEFAULT_GLYPH, _NODE_GLYPHS, _format_age, highlighted_hash
|
||
|
||
if TYPE_CHECKING:
|
||
from ..context import AppContext
|
||
|
||
#: The share of the width east of the focus's name that the fan keeps for its own reach,
|
||
#: however long the names it must make room for. Below this the graph stops reading as a
|
||
#: fan at all — the markers pile onto the focus — so past it the labels clip instead (see
|
||
#: :meth:`WalkScreen._place_neighbours`).
|
||
_MIN_FAN_REACH = 1 / 3
|
||
|
||
#: The canvas's floor in character rows: below this the fan's shape stops reading.
|
||
_CANVAS_MIN_H = 6
|
||
|
||
#: Rows the link list always keeps for itself under the canvas, however tall the
|
||
#: graph would like to be — a windowed list needs at least a few rows to scroll in.
|
||
_LIST_MIN_ROWS = 3
|
||
|
||
#: The widest any on-canvas label renders before it ellipsizes — a generous cap that most
|
||
#: real node names clear whole. It is only an upper bound: each label is *also* clamped to
|
||
#: the cells actually free between its marker and the canvas edge (see :meth:`_label_right`,
|
||
#: :meth:`_focus_anchor`, :meth:`_place_label`), so a name loses letters only when it
|
||
#: genuinely won't fit, not to a fixed short budget — the fan's placement leaves most
|
||
#: markers far more room than the old flat cap allowed.
|
||
_LABEL_W = 22
|
||
|
||
#: The fan's own label cap — higher than the focus's, and the longest a name may pull its
|
||
#: marker west to spell (JP, 2026-08-09). The fan is *where the names are read*, so it gets the
|
||
#: room; the focus keeps the tighter :data:`_LABEL_W` because every cell it spends pushes
|
||
#: the whole fan east, and its name is spelled out in full on the line below the canvas
|
||
#: anyway. 32 is the protocol's own limit on a node name, so a name that fits the mesh
|
||
#: fits here.
|
||
_FAN_LABEL_W = 32
|
||
|
||
#: The name length the fan's *default* reach is sized for. Markers whose own name is longer
|
||
#: step west from there one cell per extra cell of name; markers with a shorter one stay
|
||
#: put, so a single long name never drags the whole fan in with it.
|
||
_FAN_BASE_LABEL_W = 12
|
||
|
||
#: The fan's angular reach on each side of due east, in radians. This sets the fan's
|
||
#: *vertical* spread (the marker rows the leaves fan across); the *horizontal* reach is
|
||
#: flattened from it by :data:`_FAN_X_FLATTEN`, so a leaf's height and its easting are
|
||
#: decoupled. The whole fan stays east of the focus — neighbours to the right, labels
|
||
#: rightward — and a smaller neighbourhood uses proportionally less of the arc so two
|
||
#: nodes never sit at its extremes with nothing between them.
|
||
_FAN_HALF_ANGLE = math.radians(72)
|
||
|
||
#: How much flatter the fan's horizontal reach is than its vertical spread. A leaf's full
|
||
#: fan angle sets its *height*; its *east reach* uses only this fraction of that angle, so
|
||
#: the rim (top/bottom) leaves bow just gently back from due-east instead of curling in
|
||
#: toward the focus on a true circle. The fan then spreads across the width rather than
|
||
#: bulging in the middle with empty corners. ``1.0`` restores the old circular arc; lower
|
||
#: flattens it further. The setback still scales with the fan's angular reach, so a small
|
||
#: fan sitting near due-east barely eases back at all.
|
||
_FAN_X_FLATTEN = 0.55
|
||
|
||
#: The fan size worth giving up its outer blank rows for. Above this the canvas keeps a
|
||
#: blank row top and bottom; below it, those rows go to nodes instead — see
|
||
#: :meth:`WalkScreen._fan_capacity`.
|
||
_FAN_MIN_SLOTS = 7
|
||
|
||
#: Sentinel key for the collapsed weaker-links marker in the placed-node map. NUL can
|
||
#: never collide with a canonical id (those are hex).
|
||
_MORE = "\x00more"
|
||
|
||
#: The breadcrumb trail's own hop joiner (``›``, not the app-wide ``→``). The mark it shows
|
||
#: on whichever side holds walk that is out of view is
|
||
#: :func:`~meshterm.ui.pathline.elision_hop` itself (see :meth:`WalkScreen._trail_text`), so
|
||
#: a head the widget hid and a tail the scroll hid read as the same thing.
|
||
_TRAIL_SEP = " › "
|
||
|
||
#: How many find matches the list shows at most (the filter narrows it fast).
|
||
_MAX_MATCHES = 10
|
||
|
||
#: The narrowest the neighbour/match list's name lane shrinks to. The lane is content-sized
|
||
#: and flexes up to whatever the fixed lanes leave (see :meth:`WalkScreen._lane_widths`),
|
||
#: so as much of a long name shows as the row can spare.
|
||
_LIST_NAME_MIN = 10
|
||
|
||
#: The *widest* the key lane spans — the whole 12-hex canonical id, addressed prefix lit in
|
||
#: the node's hue (see :func:`highlighted_hash`). The lane flexes down from here on a byte
|
||
#: boundary to hand a long name more room, but never below its lit hash (see
|
||
#: :meth:`WalkScreen._lane_widths`): the name loses letters before the hash does.
|
||
_LIST_HASH_W = 12
|
||
|
||
#: Cells a link row spends *outside* its two flexing lanes (name and key), so those two can
|
||
#: size to what's left: pointer (2) + type glyph and its space (2) + the name→key space (1)
|
||
#: + gap (2) + SNR (5) + space (1) + quality bar (4) + samples (5) + source tags (5)
|
||
#: + age (5) + a reserve for the trailing ⌫/⋯ marker (8).
|
||
_LINK_ROW_FIXED = 2 + 2 + 1 + 2 + 5 + 1 + 4 + 5 + 5 + 5 + 8
|
||
|
||
#: The same, for a find-match row — which trails a short distance note rather than the SNR
|
||
#: evidence: pointer (2) + glyph and space (2) + name→key space (1) + gap (2)
|
||
#: + a distance reserve (12, for ``this device`` / ``N hops out``).
|
||
_MATCH_ROW_FIXED = 2 + 2 + 1 + 2 + 12
|
||
|
||
#: SNR (dB) → edge colour anchors, interpolated linearly and clamped at the ends: the
|
||
#: red/amber/green of the app's snr styles, so the graph and the rows agree.
|
||
_SNR_STOPS: tuple[tuple[float, RGB], ...] = (
|
||
(-15.0, (239, 68, 68)),
|
||
(0.0, (250, 204, 21)),
|
||
(10.0, (74, 222, 128)),
|
||
)
|
||
|
||
#: Edge colour for a link with no SNR reading at all (e.g. known only from a route).
|
||
_NO_READING: RGB = (100, 116, 139)
|
||
|
||
#: One-letter tags for the evidence classes backing a link, matching the path
|
||
#: composer's: T(race), R(oute), P(acket log), N(eighbour table).
|
||
_SOURCE_TAGS = {"trace": "T", "route": "R", "packet": "P", "neighbour": "N"}
|
||
|
||
|
||
def _snr_rgb(snr: Optional[float]) -> RGB:
|
||
"""The edge colour for a link's median SNR (see :data:`_SNR_STOPS`)."""
|
||
if snr is None:
|
||
return _NO_READING
|
||
if snr <= _SNR_STOPS[0][0]:
|
||
return _SNR_STOPS[0][1]
|
||
if snr >= _SNR_STOPS[-1][0]:
|
||
return _SNR_STOPS[-1][1]
|
||
(x0, c0), (x1, c1) = next(
|
||
(lo, hi) for lo, hi in zip(_SNR_STOPS, _SNR_STOPS[1:]) if lo[0] <= snr <= hi[0]
|
||
)
|
||
f = (snr - x0) / (x1 - x0)
|
||
return tuple(round(a + (b - a) * f) for a, b in zip(c0, c1)) # type: ignore[return-value]
|
||
|
||
|
||
def _scaled(rgb: RGB, factor: float) -> RGB:
|
||
"""``rgb`` dimmed (or mildly brightened) by ``factor``, clamped to byte range."""
|
||
return tuple(max(0, min(255, round(c * factor))) for c in rgb) # type: ignore[return-value]
|
||
|
||
|
||
def _freshness(last_seen: Optional[datetime], now: datetime) -> float:
|
||
"""How brightly a link draws for its evidence age: 1.0 fresh → 0.5 stale."""
|
||
if last_seen is None or getattr(last_seen, "tzinfo", None) is None:
|
||
return 0.55
|
||
age = (now - last_seen).total_seconds()
|
||
if age < 86400:
|
||
return 1.0
|
||
if age < 7 * 86400:
|
||
return 0.8
|
||
return 0.5
|
||
|
||
|
||
class WalkScreen(Screen):
|
||
"""The full-screen mesh walker: a focus neighbourhood canvas over a link list."""
|
||
|
||
floating = False
|
||
|
||
@property
|
||
def fkey_lane(self):
|
||
"""``You`` on the screen's own F3, the shared pager and its two ends on F4/F5.
|
||
|
||
``You`` is not an end of the link list — it drops the whole trail and puts the
|
||
focus back on our own node — so it never belonged behind the pager, where it had
|
||
to stand in for *Top* and leave *Bottom* blank to keep the pretence. It takes a
|
||
slot of the screen's own instead (JP, 2026-08-09), which is what F1-F3 are for,
|
||
and the pager's Shift bank goes back to meaning here what it means everywhere
|
||
else: the two ends of the list this screen scrolls. ^U reaches the same action on
|
||
a keyboard, the same chord the map spends on our node.
|
||
|
||
Both nav slots need rows to move through, which a leaf node in a sparse graph may
|
||
not have; ``You`` needs somewhere to come back *from* — a walked trail, or a find
|
||
narrowing the list — and dims once the focus is already us.
|
||
"""
|
||
from .tui.fkeys import FPair, default_lane
|
||
|
||
lane = list(default_lane(nav=len(self._rows()) > 1))
|
||
lane[2] = FPair("You", "locate", enabled=len(self._trail) > 1 or bool(self._filter))
|
||
return lane
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
session, # noqa: ANN001 - TuiSession, imported lazily to avoid a cycle
|
||
topo: MeshTopology,
|
||
contacts: dict[str, Contact],
|
||
self_label: str,
|
||
prefix_bytes: int = 0,
|
||
) -> None:
|
||
"""Create the walk over a built topology snapshot.
|
||
|
||
Args:
|
||
session: The running TUI session (repaints).
|
||
topo: The evidence graph to walk.
|
||
contacts: Contacts keyed by canonical id, for glyphs, names, and ages.
|
||
self_label: Display name for our own node (its mesh name when known).
|
||
prefix_bytes: Path-hash width to light in the hash lane (0 = none).
|
||
"""
|
||
super().__init__()
|
||
# The screen's name, and nothing else (JP, 2026-08-30). It carried the focus and
|
||
# the graph's size — ``Mesh walk — YUL-Cartierville · 42 nodes · 61 links`` — which
|
||
# ran past a 53-column title bar, and every atom of it was already on the screen
|
||
# below: the trail ends on the focus, the line under it names that node in colour
|
||
# with its hash and its ring, and how big the neighbourhood is, is what the canvas
|
||
# and the link list are *for*. Set once here rather than composed per render, so
|
||
# the top row never rewrites itself as the walk moves.
|
||
self.title = "Mesh walk"
|
||
self._session = session
|
||
self._topo = topo
|
||
self._contacts = contacts
|
||
self._self_label = self_label
|
||
self._prefix_bytes = prefix_bytes
|
||
#: The walked trail of canonical ids; the focus is its last entry. Walking
|
||
#: appends, ⌫ pops, ^U resets to us, a find teleport restarts it.
|
||
self._trail: list[str] = [topo.self_id]
|
||
#: Hops the breadcrumb line is scrolled off its *tail* end (see
|
||
#: :meth:`_trail_text`). 0 is the resting state — the focus flush right — and
|
||
#: every change to the trail returns it there.
|
||
self._trail_scroll = 0
|
||
#: ``((width, trail) → the furthest that can scroll)``, settled at render (see
|
||
#: :meth:`_trail_max_scroll`) so a keypress can clamp itself without a width.
|
||
self._trail_fit: Optional[tuple[tuple, int]] = None
|
||
#: The width the trail last rendered at, for that same clamp.
|
||
self._trail_width = 0
|
||
#: Index of the highlighted row in the current list (neighbours or matches).
|
||
self._index = 0
|
||
#: The live find-as-you-type filter ("" = off; matches every node known).
|
||
self._filter = ""
|
||
self._needs_scrub = True # braille smear scrub, exactly like the map
|
||
#: The link list's window (the list scrolls, the screen doesn't); its
|
||
#: settled capacity is the stride a PgUp/PgDn moves the highlight by.
|
||
self._list = ListWindow()
|
||
# Graph-derived memos. The topology is snapshotted once when the screen opens
|
||
# (fresh evidence means reopening), so everything derived purely from it —
|
||
# neighbour lists, the node set, BFS depths, onward-link counts — is computed
|
||
# once per key instead of once per repaint. Nothing ever invalidates these:
|
||
# they live exactly as long as the frozen graph they describe.
|
||
self._links_of_memo: dict[str, list[tuple[str, Link]]] = {}
|
||
self._all_nodes_memo: Optional[set[str]] = None
|
||
self._hops_out_memo: Optional[dict[str, int]] = None
|
||
self._onward_memo: dict[str, dict[str, int]] = {}
|
||
|
||
# --- state -------------------------------------------------------------------
|
||
|
||
@property
|
||
def _focus(self) -> str:
|
||
"""The node currently in focus (the trail's last step)."""
|
||
return self._trail[-1]
|
||
|
||
@property
|
||
def _came_from(self) -> Optional[str]:
|
||
"""The node the trail arrived from, or ``None`` at the trail's start."""
|
||
return self._trail[-2] if len(self._trail) > 1 else None
|
||
|
||
def _links_of(self, node: str) -> list[tuple[str, Link]]:
|
||
"""``(other, link)`` for every link off ``node``, strongest evidence first.
|
||
|
||
Memoized per node over the frozen graph (the sort order is pinned at first
|
||
ask — strength decays over hours, far slower than a screen stays open).
|
||
"""
|
||
memoized = self._links_of_memo.get(node)
|
||
if memoized is not None:
|
||
return memoized
|
||
now = utcnow()
|
||
pairs = [
|
||
(link.b if link.a == node else link.a, link)
|
||
for link in self._topo.links()
|
||
if node in (link.a, link.b)
|
||
]
|
||
pairs.sort(key=lambda pair: -pair[1].strength(now))
|
||
self._links_of_memo[node] = pairs
|
||
return pairs
|
||
|
||
def _all_nodes(self) -> set[str]:
|
||
"""Every node the graph mentions, plus us (walkable even when alone)."""
|
||
if self._all_nodes_memo is None:
|
||
nodes = {self._topo.self_id}
|
||
for link in self._topo.links():
|
||
nodes.add(link.a)
|
||
nodes.add(link.b)
|
||
self._all_nodes_memo = nodes
|
||
return self._all_nodes_memo
|
||
|
||
def _hops_out(self) -> dict[str, int]:
|
||
"""BFS hop distance from our own node over the evidence links.
|
||
|
||
Nodes with no path to us are absent — they are the islands, flagged as such
|
||
wherever a distance would otherwise show.
|
||
"""
|
||
if self._hops_out_memo is not None:
|
||
return self._hops_out_memo
|
||
adjacency: dict[str, set[str]] = {}
|
||
for link in self._topo.links():
|
||
adjacency.setdefault(link.a, set()).add(link.b)
|
||
adjacency.setdefault(link.b, set()).add(link.a)
|
||
depths = {self._topo.self_id: 0}
|
||
queue: deque[str] = deque([self._topo.self_id])
|
||
while queue:
|
||
node = queue.popleft()
|
||
for neighbour in adjacency.get(node, ()):
|
||
if neighbour not in depths:
|
||
depths[neighbour] = depths[node] + 1
|
||
queue.append(neighbour)
|
||
self._hops_out_memo = depths
|
||
return depths
|
||
|
||
def _is_match(self, node: str) -> bool:
|
||
"""Whether the live find query matches this node (by display name or by id).
|
||
|
||
The one predicate behind both things the query narrows: the list of teleport
|
||
candidates (:meth:`_matches`, over the whole graph) and the canvas's fan
|
||
(:meth:`_canvas_lines`, over the focus's own neighbours). No filter matches
|
||
everything, so a caller need not check for one first.
|
||
"""
|
||
needle = self._filter.strip().casefold()
|
||
if not needle:
|
||
return True
|
||
return needle in self._label(node).casefold() or needle in node.casefold()
|
||
|
||
def _matches(self) -> list[str]:
|
||
"""Nodes the find filter matches: nearest first, then by display name."""
|
||
if not self._filter.strip():
|
||
return []
|
||
depths = self._hops_out()
|
||
candidates = [node for node in self._all_nodes() if self._is_match(node)]
|
||
candidates.sort(key=lambda n: (depths.get(n, 999), self._label(n).casefold()))
|
||
return candidates[:_MAX_MATCHES]
|
||
|
||
def _rows(self) -> list[str]:
|
||
"""The selectable node ids the list currently shows: matches, or the ways onward.
|
||
|
||
Neighbour rows are :meth:`_fan_nodes` — the same set the canvas draws, in the same
|
||
strongest-first order — so row 0, where every reset of the cursor lands, is the
|
||
strongest link out of here.
|
||
"""
|
||
if self._filter:
|
||
return self._matches()
|
||
return self._fan_nodes()
|
||
|
||
# --- input -------------------------------------------------------------------
|
||
|
||
@property
|
||
def footer_hint(self) -> str: # type: ignore[override]
|
||
"""Walking keys — or the live find query while one is being typed.
|
||
|
||
Every atom fits the 72-cell budget only because two of them take turns. ``←→
|
||
trail`` appears exactly while the breadcrumb has more walk than width (an inert
|
||
key is never advertised — the hint line's own long-standing rule, and the F-lane's
|
||
after it), and it takes the place of ``type to find``: a walk deep enough to
|
||
overflow the trail is one where the scroll is the unknown key, while typing
|
||
announces itself the instant a letter lands, replacing this whole line with the
|
||
query. The find is self-teaching; the scroll had no way to be.
|
||
"""
|
||
if self._filter:
|
||
return f"find: {self._filter}▏ · ↑↓ move · Enter focus · ⌫ erase · Esc clear"
|
||
scrolls = self._trail_width > 0 and bool(self._trail_max_scroll(self._trail_width))
|
||
atoms = ["↑↓ move"]
|
||
if scrolls:
|
||
atoms.append("←→ trail")
|
||
atoms += ["Enter focus", "⌫ back", "^U you"]
|
||
if not scrolls:
|
||
atoms.append("type to find")
|
||
atoms.append("Esc back")
|
||
return " · ".join(atoms)
|
||
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
"""Move the highlight, walk, back up, find, or dismiss.
|
||
|
||
Home/End are the list's own ends here, as on every other scrolling screen; the jump
|
||
back to our own node is ``locate`` (^U, and the lane's F3), which is not a place in
|
||
this list at all — see :attr:`fkey_lane`.
|
||
"""
|
||
rows = self._rows()
|
||
if action == "escape":
|
||
if self._filter:
|
||
self._filter = ""
|
||
self._index = 0
|
||
else:
|
||
self.resolve(None)
|
||
return
|
||
elif 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:
|
||
# The highlight pages by one list windowful: the window follows the
|
||
# highlight, so paging the view without it would just snap straight back.
|
||
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 == "enter":
|
||
self._walk(rows)
|
||
elif action == "backspace":
|
||
if self._filter:
|
||
self._filter = self._filter[:-1]
|
||
self._index = 0
|
||
elif len(self._trail) > 1:
|
||
self._trail.pop()
|
||
self._index = 0
|
||
self._trail_scroll = 0
|
||
elif action in ("home", "ctrl_home") and rows:
|
||
self._index = 0
|
||
elif action in ("end", "ctrl_end") and rows:
|
||
self._index = len(rows) - 1
|
||
elif action == "left":
|
||
# The breadcrumb scrolls, the list doesn't — it is the one line here with more
|
||
# content than width. Clamped against the width the last paint settled, so
|
||
# holding ← parks at the head instead of banking presses to undo (0 until the
|
||
# trail actually overflows, which makes the key inert on a short walk).
|
||
self._trail_scroll = min(
|
||
self._trail_scroll + 1, self._trail_max_scroll(self._trail_width)
|
||
)
|
||
elif action == "right":
|
||
self._trail_scroll = max(0, self._trail_scroll - 1)
|
||
elif action == "locate":
|
||
# ^U (and F3): abandon the walk rather than move within it — the trail goes
|
||
# back to just us and any find narrowing the list is dropped with it.
|
||
self._trail = [self._topo.self_id]
|
||
self._filter = ""
|
||
self._index = 0
|
||
self._trail_scroll = 0
|
||
elif action == "text":
|
||
if not data.isspace() or self._filter: # never begin the filter with a space
|
||
self._filter += data
|
||
self._index = 0
|
||
elif action == "space" and self._filter:
|
||
self._filter += " " # node names carry spaces; only meaningful mid-query
|
||
self._needs_scrub = True
|
||
self._session.invalidate()
|
||
|
||
def _walk(self, rows: list[str]) -> None:
|
||
"""Focus the highlighted row: a step along the trail, or a find teleport."""
|
||
if not rows:
|
||
return
|
||
target = rows[min(self._index, len(rows) - 1)]
|
||
if self._filter:
|
||
# A teleport restarts the trail at the target — the walk didn't cross the
|
||
# gap, so pretending it did would make ⌫ retrace a path never taken.
|
||
self._trail = [target]
|
||
self._filter = ""
|
||
elif target in self._trail:
|
||
# Revisiting a node already on the trail — stepping back through the west
|
||
# node, or looping round to an earlier one — truncates the stack to that
|
||
# node's first appearance. We drop the circular stretch we walked to get
|
||
# back here rather than recording the round trip; losing the loop is the point.
|
||
self._trail = self._trail[: self._trail.index(target) + 1]
|
||
else:
|
||
self._trail.append(target)
|
||
self._index = 0
|
||
self._trail_scroll = 0 # the new focus is the news; put it back in view
|
||
|
||
# --- smear scrub (same fallback-glyph problem as the map) ---------------------
|
||
|
||
def consume_edge_scrub(self) -> int:
|
||
"""Right-edge columns to force-repaint after a redraw (see the map screen)."""
|
||
if not self._needs_scrub:
|
||
return 0
|
||
self._needs_scrub = False
|
||
return 2
|
||
|
||
# --- rendering -----------------------------------------------------------------
|
||
|
||
def render_body(self, width: int) -> list[str]:
|
||
"""Render the trail, the focus line, the canvas, and the windowed link list.
|
||
|
||
The body is laid out to fit the frame's viewport exactly: the canvas takes
|
||
the majority of the rows (a touch less of the share on tall terminals), the
|
||
chrome around it holds still, and whatever remains is the link list's
|
||
window — only its rows scroll, inside :meth:`_list_lines`.
|
||
"""
|
||
links = self._topo.links()
|
||
rows = self._rows()
|
||
self._index = max(0, min(self._index, len(rows) - 1)) if rows else 0
|
||
if not links:
|
||
return self._empty_state(width)
|
||
|
||
depths = self._hops_out()
|
||
selected = rows[self._index] if rows else None
|
||
viewport = self._scroll_viewport # recorded by the frame before this render
|
||
|
||
header = self._header_lines(width, depths)
|
||
chrome = len(header) + 3 + self._query_row() # legend, blank, list heading (+find echo)
|
||
canvas_h = self._canvas_height(viewport, chrome)
|
||
list_win = max(1, viewport - chrome - canvas_h)
|
||
|
||
lines: list[str] = list(header)
|
||
lines.extend(self._canvas_lines(width, canvas_h, selected))
|
||
lines.append(render_to_ansi(self._legend(), width, no_wrap=True))
|
||
lines.append("")
|
||
lines.extend(self._list_lines(width, rows, depths, list_win))
|
||
self._scroll_total = max(1, len(lines))
|
||
return lines
|
||
|
||
def _query_row(self) -> int:
|
||
"""Whether this paint spends a list row echoing the find query (1) or not (0).
|
||
|
||
Only where the footer isn't drawn (:attr:`~meshterm.platforms.Platform.footer_fkeys`):
|
||
there the hint line carrying the query never reaches the screen, so without this row
|
||
the list would narrow to matches with no sign of what was typed to narrow it. The row
|
||
sits directly above the ``Matches`` heading — above what it narrows, as on every other
|
||
find-as-you-type screen — and the list, not the canvas, cedes the line for it.
|
||
"""
|
||
return 1 if self._filter and get_platform().footer_fkeys else 0
|
||
|
||
def _canvas_height(self, viewport: int, chrome: int) -> int:
|
||
"""Rows the canvas takes: the majority of the screen, ceded where pointless.
|
||
|
||
The share starts at ~62% of the viewport and tapers toward half on tall
|
||
terminals (a huge graph area buys little once the fan is legible, while the
|
||
list keeps earning rows). A sparse neighbourhood caps it lower — a two-node
|
||
link needs no half-screen void — and the link list always keeps its
|
||
:data:`_LIST_MIN_ROWS` under the fixed chrome.
|
||
"""
|
||
crowd = len(self._fan_nodes())
|
||
share = 0.62 - 0.12 * min(max(viewport - 20, 0) / 24.0, 1.0)
|
||
height = round(viewport * share)
|
||
height = min(height, max(_CANVAS_MIN_H, 2 * crowd + 1))
|
||
height = min(height, viewport - chrome - _LIST_MIN_ROWS)
|
||
return max(4, height)
|
||
|
||
def _header_lines(self, width: int, depths: dict[str, int]) -> list[str]:
|
||
"""The breadcrumb trail and the focus node's identity line.
|
||
|
||
The trail draws always — even at the root, where it is just our own node — so the
|
||
breadcrumb is a constant fixture and the header keeps a steady height whether you
|
||
have walked or not, rather than the whole body shifting up a row the moment you take
|
||
the first step.
|
||
"""
|
||
return [
|
||
render_to_ansi(self._trail_text(width), width, no_wrap=True),
|
||
render_to_ansi(self._focus_line(depths), width, no_wrap=True),
|
||
]
|
||
|
||
def _trail_text(self, width: int) -> Text:
|
||
"""The breadcrumb trail as a path line: one line, ← → scrollable, never wrapped.
|
||
|
||
The walk *is* a path — us, then every node stepped through, ending on the focus —
|
||
so it renders through :class:`~meshterm.ui.pathline.PathLine` like every other hop
|
||
sequence in the app: powerline chips wherever the terminal can draw them, the
|
||
trail's own ``›`` arrows where it can't. Each hop wears its node's key-derived hue
|
||
(ours the white ``you``; a node known only by a bare hash stays muted — colour is
|
||
reserved for keyed identities), so the trail and the rows below it agree on who is
|
||
who.
|
||
|
||
The line never wraps. At rest, a walk that outgrows the width eats into its
|
||
*head* (:data:`~meshterm.ui.pathline.ELIDE_HEAD`) rather than a route's usual
|
||
tail: the oldest steps go behind a leading ``⋯`` and what survives snaps flush
|
||
against the **right** edge, so the focus and the steps that just led to it are
|
||
what a glance lands on.
|
||
|
||
← and → then **scroll it** (JP, 2026-08-09), a hop at a time, off the tail end:
|
||
the elided head is a real part of the walk and a long one had no way to be read
|
||
at all. A scrolled line grows its own trailing ``⋯`` — the focus is now the part
|
||
out of view — so the mark on each side always means the same thing, more walk
|
||
that way. Scrolling stops where the head comes into view rather than running the
|
||
line off the edge, and any change to the trail itself resets it: a walk, a step
|
||
back, a teleport and ^U all end with the focus in view, which is where the next
|
||
move is read from.
|
||
"""
|
||
hops = [self._trail_hop(node) for node in self._trail]
|
||
self._trail_width = width
|
||
limit = self._trail_max_scroll(width)
|
||
self._trail_scroll = max(0, min(self._trail_scroll, limit))
|
||
if not limit and not self._trail_scroll:
|
||
full = PathLine(hops, separator=_TRAIL_SEP).text()
|
||
if full.cell_len <= width:
|
||
return full
|
||
kept = hops[: len(hops) - self._trail_scroll]
|
||
if self._trail_scroll:
|
||
kept.append(elision_hop()) # the focus is off to the right
|
||
fitted = PathLine(kept, separator=_TRAIL_SEP).ellipsized(width, elide=ELIDE_HEAD)
|
||
snapped = Text(" " * max(0, width - fitted.cell_len)) # snap the tail to the edge
|
||
snapped.append_text(fitted)
|
||
return snapped
|
||
|
||
def _trail_max_scroll(self, width: int) -> int:
|
||
"""How far ← may scroll the trail: the step that first brings its head into view.
|
||
|
||
Scrolling past that only shortens a line that already shows every hop it has, so
|
||
the walk stops there — the same claim the F-lane makes when it dims a key that
|
||
would do nothing. ``0`` for a trail that fits, which is what makes ← inert on a
|
||
short walk without the handler needing to know the width.
|
||
|
||
Memoized on ``(width, trail)``: it costs a rendering per candidate step and the
|
||
answer only moves when the walk or the terminal does.
|
||
"""
|
||
key = (width, tuple(self._trail))
|
||
if self._trail_fit is not None and self._trail_fit[0] == key:
|
||
return self._trail_fit[1]
|
||
hops = [self._trail_hop(node) for node in self._trail]
|
||
limit = 0
|
||
if PathLine(hops, separator=_TRAIL_SEP).text().cell_len > width:
|
||
mark = elision_hop()
|
||
for dropped in range(1, len(hops)):
|
||
limit = dropped
|
||
head = PathLine(hops[: len(hops) - dropped] + [mark], separator=_TRAIL_SEP)
|
||
if head.text().cell_len <= width:
|
||
break
|
||
self._trail_fit = (key, limit)
|
||
return limit
|
||
|
||
def _trail_hop(self, node: str) -> PathHop:
|
||
"""One walked step as a path hop: its display name in its own identity colour.
|
||
|
||
Our own node takes the app-wide :data:`~meshterm.ui.pathline.SELF_GLYPH` instead
|
||
of its name: every walk sets out from us, so the star says in one cell what the
|
||
trail's most width-starved line would otherwise spend a whole name on — and the
|
||
cells it frees are steps that stay in view before the head has to be elided.
|
||
"""
|
||
style = self._list_name_style(node)
|
||
if style == "you":
|
||
return PathHop(SELF_GLYPH, you=True)
|
||
return PathHop(
|
||
self._label(node),
|
||
key=None if style == "node.unknown" else node,
|
||
)
|
||
|
||
def _focus_line(self, depths: dict[str, int]) -> Text:
|
||
"""Who is in focus: glyph, name with its parenthesized hash, distance, and recency.
|
||
|
||
The leading glyph carries the node type as its *shape* and the node's own key hue
|
||
as its *colour* (:meth:`_glyph_style`, matching the canvas marker), so the line no
|
||
longer spells the type out; the name reads ``name (hash)`` — the addressed
|
||
path-hash in parentheses, its digits lit in the node's hue — rather than a bare
|
||
slice of the key.
|
||
|
||
The ``N hops out`` distance is the *shortest* observed path from us to this node
|
||
(a BFS over the whole evidence graph, :meth:`_hops_out`) — the node's ring in the
|
||
mesh. It is deliberately **not** the length of the breadcrumb trail above, which is
|
||
the route you happened to *walk* to reach the focus: wander out a long way and
|
||
double back, or step to a node also reachable by a shorter link, and the walk is
|
||
longer than the ring. The two answer different questions — "how near is this node?"
|
||
versus "how did I get here?" — so a shorter "hops out" than the trail is correct,
|
||
not a miscount.
|
||
"""
|
||
node = self._focus
|
||
glyph, _type_color = self._glyph(node)
|
||
contact = self._contacts.get(node)
|
||
line = Text()
|
||
line.append(glyph, style=self._glyph_style(node))
|
||
line.append(" ")
|
||
line.append(self._label(node), style=self._list_name_style(node))
|
||
short = self._short_hash(node)
|
||
if short:
|
||
line.append(" (", style="muted")
|
||
line.append_text(highlighted_hash(short, self._prefix_bytes,
|
||
known=self._known(node)))
|
||
line.append(")", style="muted")
|
||
if node == self._topo.self_id:
|
||
line.append(" · this device", style="muted")
|
||
else:
|
||
if node not in depths:
|
||
line.append(" · island — no observed path to you", style="warn")
|
||
else:
|
||
ring = depths[node]
|
||
line.append(f" · {ring} hop{'s' if ring != 1 else ''} out", style="muted")
|
||
if contact is not None and contact.last_seen is not None:
|
||
secs = max(0.0, (utcnow() - contact.last_seen).total_seconds())
|
||
line.append(f" · heard {_format_age(secs)}", style="muted")
|
||
return line
|
||
|
||
def _short_hash(self, node: str) -> str:
|
||
"""The node's addressable path-hash as hex, or ``''`` for a placeholder id.
|
||
|
||
The first ``prefix_bytes`` bytes (at least one, so there is always a hash to show)
|
||
of a hex id; a non-hex stand-in like ``"local"`` — our own node with no key — has
|
||
no hash and yields the empty string, so the focus line drops the parenthetical.
|
||
"""
|
||
raw = node.lower()
|
||
if not raw or any(c not in "0123456789abcdef" for c in raw):
|
||
return ""
|
||
return raw[: max(1, self._prefix_bytes) * 2]
|
||
|
||
# -- the canvas --
|
||
|
||
def _canvas_lines(
|
||
self, width: int, canvas_h: int, selected: Optional[str]
|
||
) -> list[str]:
|
||
"""Draw the focus neighbourhood: focus at the far west, the strongest fan east.
|
||
|
||
The focus icon sits at the far west with its name to its right; the fan spreads
|
||
east across the available width (see :meth:`_place_neighbours`), each neighbour
|
||
named to the right of its marker. Every marker is a whole **node-type mark**, glyph
|
||
and colour both (:meth:`_marker_rgb`), the same one the map and the route graph
|
||
pin that node with; identity rides the *name* beside it, in the key hue. No marker
|
||
is recoloured, not even the selection's — the highlighted row is shown by its
|
||
*label* going white and by the lit route.
|
||
|
||
While a find query is being typed the fan is narrowed to the neighbours it matches
|
||
— the east is *the ways onward*, which is what the query is asking about — and the
|
||
focus holds through it: it is the walk so far, not a candidate, so the picture keeps
|
||
its bearings while the choices thin out. The node the walk came *from* is not drawn
|
||
at all (JP, 2026-08-09): it is behind you, ⌫ is how you go back to it, and drawing
|
||
it made the one thing on the canvas that isn't a way onward look like one.
|
||
|
||
Only as many neighbours as the area can carry get their own marker (see
|
||
:meth:`_fan_capacity`); the weaker rest collapse into one ``…`` marker at the fan's
|
||
foot. Highlighting a collapsed row from the list makes that marker *stand in for*
|
||
the highlighted node — its own type mark, its own name, and a grey ``(2/17)`` for
|
||
where it sits among the collapsed — so a selection is never invisible and never
|
||
mistaken for a node of its own. Every edge leaves the *right end of the focus name*
|
||
so a connecting line never crosses a label, and the edge to the selected link draws
|
||
brightest and undimmed over the rest.
|
||
"""
|
||
canvas = MapCanvas(width, canvas_h)
|
||
by_other = dict(self._links_of(self._focus))
|
||
fan = self._fan_nodes()
|
||
capacity = self._fan_capacity(canvas_h)
|
||
if len(fan) > capacity:
|
||
# The … marker takes a slot of its own, so it has to earn it: it stands in for
|
||
# the last slot's worth *plus* whatever didn't fit, never for a single node.
|
||
shown, hidden = fan[: capacity - 1], fan[capacity - 1:]
|
||
else:
|
||
shown, hidden = fan, []
|
||
slots = len(shown) + (1 if hidden else 0)
|
||
fx, fy = self._focus_pos(width, canvas_h, slots)
|
||
ax, ay, focus_name = self._focus_anchor(width, canvas_h, slots)
|
||
stand_in = selected if selected in hidden else None
|
||
# The … slot is laid out for the label it wears at rest. A stand-in's name is not
|
||
# allowed to move it: the geometry must not depend on the selection, or the whole
|
||
# fan would shuffle as the cursor walked the collapsed nodes. A long name that
|
||
# doesn't fit the slot simply truncates (JP, 2026-08-09).
|
||
placed = self._place_neighbours(
|
||
width, canvas_h, shown, bool(hidden), more_label=f"+{len(hidden)} weaker"
|
||
)
|
||
|
||
# Edges first (markers and labels overprint them), coloured by SNR and faded by
|
||
# evidence age. Every edge leaves the focus's name-end anchor and lands on its
|
||
# neighbour's marker. The selected link's edge draws brightest and on top, shedding
|
||
# the age fade so it reads as one lit thread over the dimmer rest (the age is still
|
||
# legible in the row's age column). The collapsed marker's edge is slate — unless
|
||
# the selection hides in it, when it becomes that node's own edge.
|
||
now = utcnow()
|
||
for other, (x, y) in placed.items():
|
||
if other == _MORE:
|
||
if selected in hidden:
|
||
color = _scaled(_snr_rgb(by_other[selected].median_snr), 1.0)
|
||
priority = 4
|
||
else:
|
||
color = _scaled(_NO_READING, 0.6)
|
||
priority = 1
|
||
else:
|
||
link = by_other[other]
|
||
if other == selected:
|
||
color = _scaled(_snr_rgb(link.median_snr), 1.0)
|
||
priority = 4
|
||
else:
|
||
color = _scaled(_snr_rgb(link.median_snr), _freshness(link.last_seen, now))
|
||
priority = 2
|
||
canvas.draw_line([(ax, ay), (x, y)], color, priority)
|
||
|
||
# The focus marker at the far west, its name to the RIGHT — icon left, name right,
|
||
# the walk's reading way. The edges have already left the name-end, so they run east
|
||
# clear of the label rather than through it.
|
||
glyph, _colour = self._glyph(self._focus)
|
||
canvas.marker(fx, fy, glyph, self._marker_rgb(self._focus))
|
||
self._label_right(canvas, fx, fy, focus_name, self._label_rgb(self._focus))
|
||
|
||
# Markers first, then labels. Every marker wears its node type's colour — the
|
||
# selection is shown by its white *label* and the lit route, never by recolouring
|
||
# a mark that means something else. Labels are laid most-important-first (the
|
||
# selection, then the strongest links) so the collision check drops the least
|
||
# important where two would overprint.
|
||
white = (255, 255, 255)
|
||
for other, (x, y) in placed.items():
|
||
node = stand_in if other == _MORE else other
|
||
if node is None:
|
||
canvas.marker(x, y, "…", mark_rgb(_UNKNOWN[1]))
|
||
continue
|
||
glyph, _colour = self._glyph(node)
|
||
canvas.marker(x, y, glyph, self._marker_rgb(node))
|
||
if selected is not None and selected in placed:
|
||
x, y = placed[selected]
|
||
self._place_label(canvas, x, y, self._label(selected), white)
|
||
for other in shown:
|
||
if other != selected:
|
||
x, y = placed[other]
|
||
self._label_right(canvas, x, y, self._label(other), self._label_rgb(other))
|
||
if _MORE in placed:
|
||
x, y = placed[_MORE]
|
||
grey = mark_rgb(_UNKNOWN[1])
|
||
if stand_in is not None:
|
||
# The marker is that node now: its own mark, and its name in the selection
|
||
# white — a collapsed node is *only ever drawn while selected*, so white is
|
||
# what it is here, exactly as it would be if it had a marker of its own. The
|
||
# rank among the collapsed set sits *west* of the mark (JP, 2026-08-09)
|
||
# rather than trailing the name, so the name still ends where every other
|
||
# name on the fan ends and the count reads as an annotation on the marker
|
||
# instead of part of what the node is called. That count is what keeps the
|
||
# stand-in honest: a lone name would claim the fan has one more member than
|
||
# it drew.
|
||
self._label_left(canvas, x, y, self._more_rank(stand_in, hidden), grey)
|
||
self._label_right(canvas, x, y, self._label(stand_in), white)
|
||
else:
|
||
self._label_right(canvas, x, y, f"+{len(hidden)} weaker", grey)
|
||
|
||
return canvas.to_ansi_lines()
|
||
|
||
@staticmethod
|
||
def _more_rank(node: str, hidden: list[str]) -> str:
|
||
"""``(2/17)`` — where ``node`` sits among the collapsed links, and how many there are."""
|
||
return f"({hidden.index(node) + 1}/{len(hidden)})"
|
||
|
||
def _fan_nodes(self) -> list[str]:
|
||
"""The focus's ways *onward*: every neighbour but the one the walk came from.
|
||
|
||
The single definition of what the east of this screen is about, shared by the
|
||
canvas and the link list so the picture and the rows can never disagree about what
|
||
is on offer — including under a find, which narrows both. The came-from node is
|
||
excluded (JP, 2026-08-09) — it is where you have been, ⌫ is how you return to it,
|
||
and listing it as a link both invited a walk that just undoes the last one and cost
|
||
the list its top row: with it gone, the strongest link is always the row the cursor
|
||
opens on.
|
||
"""
|
||
back = self._came_from
|
||
return [
|
||
other for other, _link in self._links_of(self._focus)
|
||
if other != back and self._is_match(other)
|
||
]
|
||
|
||
def _fan_capacity(self, canvas_h: int) -> int:
|
||
"""How many marker rows the canvas carries — the ``…`` marker's own among them.
|
||
|
||
The fan is a **row grid, not an arc's spread** (JP, 2026-08-09): one marker per
|
||
row with a blank row between each, and a blank row above and below the block —
|
||
``2n + 1`` rows for ``n`` markers. Even spacing is what makes a fan of five read as
|
||
five rather than as a clump at the rim, which is what an evenly-spaced *angle*
|
||
sweep produced (its rows fell out of a sine, so they crowded top and bottom).
|
||
|
||
The outer blank rows are the part that gives: a canvas too short to reach
|
||
:data:`_FAN_MIN_SLOTS` neighbours with them drops them and fits ``n`` in ``2n - 1``
|
||
instead. Air is worth a row until it costs a *node* — seeing the neighbourhood is
|
||
what the screen is for.
|
||
"""
|
||
padded = (canvas_h - 1) // 2
|
||
if padded >= _FAN_MIN_SLOTS:
|
||
return max(3, padded)
|
||
return max(3, (canvas_h + 1) // 2)
|
||
|
||
def _fan_rows(self, canvas_h: int, slots: int) -> list[int]:
|
||
"""The canvas rows the fan's ``slots`` markers sit on, top to bottom.
|
||
|
||
Centred as one block, which is what hands back the outer blank row whenever the
|
||
canvas is taller than the block needs — and drops it, evenly, when it isn't (see
|
||
:meth:`_fan_capacity`).
|
||
"""
|
||
if slots <= 0:
|
||
return []
|
||
span = 2 * slots - 1
|
||
top = max(0, (canvas_h - span) // 2)
|
||
return [min(canvas_h - 1, top + 2 * i) for i in range(slots)]
|
||
|
||
def _focus_pos(self, width: int, canvas_h: int, slots: int = 0) -> tuple[int, int]:
|
||
"""The focus marker's dot position: near the west edge, name and fan to its east.
|
||
|
||
The icon hugs the left so its name (drawn to the right) and the neighbour fan get
|
||
the whole width to spread across — the graph aired out rather than squeezed into
|
||
the middle.
|
||
|
||
Vertically it sits level with the **fan's** middle rather than the canvas's: the
|
||
block of ``slots`` markers is centred, and the focus is centred on *it*, so the
|
||
edges leaving it fan out symmetrically however the block's parity falls. With no
|
||
fan to be level with (``slots`` 0 — a leaf node, or a find matching nothing here)
|
||
it takes the canvas midline.
|
||
"""
|
||
x = max(4, (width * 2) // 12)
|
||
rows = self._fan_rows(canvas_h, slots)
|
||
row = (rows[0] + rows[-1]) / 2 if rows else (canvas_h - 1) / 2
|
||
return x, round(row * 4 + 2)
|
||
|
||
def _focus_anchor(self, width: int, canvas_h: int, slots: int = 0) -> tuple[int, int, str]:
|
||
"""The focus's edge-attach point and its on-canvas name.
|
||
|
||
The focus icon sits at the far west (:meth:`_focus_pos`) with its name to the
|
||
*right*; every edge leaves the **right end of that name** so the connecting lines
|
||
never cross the label. Returns the attach point in dot coordinates and the name as
|
||
it is drawn (clipped to the room between the icon and the canvas edge).
|
||
"""
|
||
fx, fy = self._focus_pos(width, canvas_h, slots)
|
||
icon_cx = fx >> 1
|
||
name = self._clip(self._label(self._focus), width - (icon_cx + 2))
|
||
anchor_cx = icon_cx + 2 + cell_len(name) + 1
|
||
return anchor_cx * 2, fy, name
|
||
|
||
def _place_neighbours(
|
||
self,
|
||
width: int,
|
||
canvas_h: int,
|
||
shown: list[str],
|
||
more: bool,
|
||
*,
|
||
more_label: str = "",
|
||
) -> dict[str, tuple[int, int]]:
|
||
"""Dot-space positions for the drawn fan: a row grid east of the focus's name.
|
||
|
||
Everyone shown takes a row of their own east of the focus's name-end anchor,
|
||
strongest link at the top and weakest at the bottom — the same order as the list
|
||
below, so the picture and the rows correspond — with the collapsed ``…`` marker
|
||
(keyed :data:`_MORE`) taking the fan's last slot. The rows come from
|
||
:meth:`_fan_rows`: evenly spaced, one blank row between each, the block centred.
|
||
|
||
The easting is an arc, reined in **per marker** by the name that marker has to
|
||
spell (JP, 2026-08-09). The fan reaches for a tip sized to an ordinary name
|
||
(:data:`_FAN_BASE_LABEL_W`), and then each marker whose own name wants more room
|
||
than that steps west by exactly the difference — so a single long name costs *its*
|
||
row some reach and leaves the rest of the fan where it was. Sizing the whole fan to
|
||
the longest name (what this did first) meant one 29-cell repeater pulled every
|
||
marker west with it; not sizing at all (what it did before that) meant a fixed
|
||
20-cell margin ellipsized anything longer on a canvas with room to spare.
|
||
|
||
A marker never comes further west than :data:`_MIN_FAN_REACH` of the width,
|
||
whatever its name wants: past that the markers pile onto the focus and the graph
|
||
stops reading as a fan at all, so a name too long for what's left is truncated by
|
||
the label placer instead.
|
||
|
||
Only the *row* of a leaf sets its angle; its east reach bows by
|
||
:data:`_FAN_X_FLATTEN` of that, so the fan curves gently rather than standing as a
|
||
flat column, while the rim leaves still reach well east instead of curling back
|
||
toward the focus on a true circle. A small fan uses proportionally less of the arc,
|
||
so two neighbours sit near due east rather than at opposite rims. Anchoring at the
|
||
name-end (rather than at the icon) hands the fan the whole width east of the focus
|
||
label to breathe in.
|
||
|
||
Args:
|
||
width: The canvas width in cells.
|
||
canvas_h: The canvas height in cells.
|
||
shown: The neighbours drawn with markers of their own, strongest first.
|
||
more: Whether a collapsed ``…`` marker takes the last slot.
|
||
more_label: What that marker is labelled at rest, so its slot is laid out for
|
||
the label it *usually* wears rather than for a passing selection.
|
||
"""
|
||
placed: dict[str, tuple[int, int]] = {}
|
||
keys = list(shown) + ([_MORE] if more else [])
|
||
if not keys:
|
||
return placed
|
||
dot_w = width * 2
|
||
ax, _ay, _name = self._focus_anchor(width, canvas_h, len(keys))
|
||
rows = self._fan_rows(canvas_h, len(keys))
|
||
labels = [self._label(node) for node in shown] + ([more_label] if more else [])
|
||
floor = ax + (dot_w - ax) * _MIN_FAN_REACH
|
||
# The marker's own cell, a blank, then the label: that is what has to sit east of a
|
||
# marker, in cells, doubled into dot space.
|
||
rx = max(floor, 2 * (width - 2 - _FAN_BASE_LABEL_W)) - ax
|
||
phi = _FAN_HALF_ANGLE * min(1.0, (len(keys) - 1) / 5.0)
|
||
for i, (node, row, label) in enumerate(zip(keys, rows, labels)):
|
||
angle = 0.0 if len(keys) == 1 else -phi + (2 * phi) * i / (len(keys) - 1)
|
||
x = ax + rx * math.cos(angle * _FAN_X_FLATTEN)
|
||
room = 2 * (width - 2 - min(cell_len(label), _FAN_LABEL_W))
|
||
placed[node] = (
|
||
round(max(floor, min(x, room))),
|
||
row * 4 + 2, # mid-cell, so an edge meets the glyph
|
||
)
|
||
return placed
|
||
|
||
@staticmethod
|
||
def _clip(label: str, room: int, cap: int = _LABEL_W) -> str:
|
||
"""``label`` fit to ``room`` cells: whole if it fits, else ellipsized (``…`` alone
|
||
at one cell, nothing at zero). The cap and the room-to-edge both flow through here,
|
||
so a name is only ever shortened as far as it truly must be."""
|
||
room = min(room, cap)
|
||
if room <= 0:
|
||
return ""
|
||
if len(label) <= room:
|
||
return label
|
||
return "…" if room == 1 else label[: room - 1] + "…"
|
||
|
||
def _place_label(
|
||
self, canvas: MapCanvas, x: int, y: int, label: str, rgb: RGB
|
||
) -> None:
|
||
"""Place one marker label (right of the marker when it fits, else left),
|
||
retrying a row below then above on collision.
|
||
|
||
The label is clamped to whichever side has more room — the cells free to the
|
||
right of the marker, or to its left — so the selection keeps as much of its name
|
||
as the canvas allows before ellipsizing."""
|
||
cx = x >> 1
|
||
room = max(canvas.cell_w - (cx + 2), cx - 1) # the roomier of right / left
|
||
label = self._clip(label, room)
|
||
if not label:
|
||
return
|
||
for dy in (0, 4, -4):
|
||
if canvas.marker_label(x, y + dy, label, rgb):
|
||
return
|
||
|
||
def _label_right(
|
||
self, canvas: MapCanvas, x: int, y: int, label: str, rgb: RGB
|
||
) -> None:
|
||
"""Place a node's label to the *right* of its marker, dodging by row.
|
||
|
||
Every node but the selected one (which keeps the two-sided
|
||
:meth:`_place_label`) is named to the right of its icon — the reading
|
||
direction of the walk. The label tries the marker's own row first, then a
|
||
row below and above to slip past a crowded neighbour; if every checked row
|
||
is blocked it is stamped to the right regardless, so a node is never left a
|
||
bare glyph. The name is clamped to the cells actually free between the marker
|
||
and the canvas edge — which :meth:`_place_neighbours` has already sized the fan
|
||
to keep, so on any canvas wide enough the clamp never bites.
|
||
"""
|
||
cx, cy = x >> 1, y >> 2
|
||
start = cx + 2
|
||
label = self._clip(label, canvas.cell_w - start, cap=_FAN_LABEL_W)
|
||
if not label:
|
||
return
|
||
for dy in (0, 1, -1, 2, -2):
|
||
if canvas._place_run(start, cy + dy, label, rgb, bold=True, checked=True):
|
||
return
|
||
canvas._place_run(start, cy, label, rgb, bold=True)
|
||
|
||
def _label_left(
|
||
self, canvas: MapCanvas, x: int, y: int, label: str, rgb: RGB
|
||
) -> None:
|
||
"""Place a short annotation to the *left* of a marker, on the marker's own row.
|
||
|
||
The one thing drawn on that side (the collapsed marker's rank — see
|
||
:meth:`_canvas_lines`), and it stays on the row it annotates rather than dodging:
|
||
a counter that slipped a row would read as belonging to the neighbour above. West
|
||
of a fan marker is the marker's own incoming edge and little else, so it is placed
|
||
unconditionally and simply overprints the braille it lands on — the same fallback
|
||
:meth:`_label_right` ends on. It carries its own trailing blank into the run so the
|
||
gap before the mark is a real gap: left unwritten, the incoming edge's braille
|
||
threads straight through it and glues the counter to the glyph.
|
||
"""
|
||
cx, cy = x >> 1, y >> 2
|
||
label = self._clip(label, cx - 1, cap=_FAN_LABEL_W)
|
||
if not label:
|
||
return
|
||
canvas._place_run(cx - 1 - cell_len(label), cy, label + " ", rgb, bold=True)
|
||
|
||
def _legend(self) -> Text:
|
||
"""The one-line glyph legend and edge key under the canvas.
|
||
|
||
A key to both halves of a mark, now that the canvas draws marks whole: each role's
|
||
glyph in the very colour it wears up there, so the line is a sample rather than a
|
||
shape chart. The pairs come from the same places :meth:`_glyph` draws them from, so
|
||
the key cannot drift from the picture — including on the console, where the
|
||
``type.*`` names are what let each mark pick its slot deliberately rather than
|
||
landing wherever a downsample of the hex drops it. The words stay muted: they are
|
||
chrome, the marks are the content.
|
||
"""
|
||
legend = Text()
|
||
for (glyph, colour), word in (
|
||
(_SELF, " you "),
|
||
(_NODE_GLYPHS[NODE_TYPE_REPEATER], " repeater "),
|
||
(_DEFAULT_GLYPH, " node "),
|
||
(_UNKNOWN, " unknown"),
|
||
):
|
||
legend.append(glyph, style=colour)
|
||
legend.append(word, style="muted")
|
||
legend.append(" · edge = SNR · faint = stale", style="muted")
|
||
return legend
|
||
|
||
# -- the list --
|
||
|
||
def _list_lines(
|
||
self, width: int, rows: list[str], depths: dict[str, int], win: int
|
||
) -> list[str]:
|
||
"""The selectable rows, windowed to ``win`` lines under a pinned heading.
|
||
|
||
Only the rows scroll — the heading (and everything above it) holds still.
|
||
When the list outgrows the window, faint ``↑/↓ n more`` markers take the
|
||
window's edge rows and the highlight is kept inside what remains; the
|
||
window's row count becomes the PgUp/PgDn stride.
|
||
"""
|
||
out: list[str] = []
|
||
if self._query_row():
|
||
out.append(query_line(self._filter, width))
|
||
if self._filter:
|
||
heading = Text("Matches", style="accent")
|
||
heading.append(" · nearest first · Enter focuses", style="muted")
|
||
out.append(render_to_ansi(heading, width, no_wrap=True))
|
||
if not rows:
|
||
out.append(render_to_ansi(Text("no matches", style="muted"), width))
|
||
return out
|
||
|
||
name_w, key_w = self._lane_widths(width, rows, _MATCH_ROW_FIXED)
|
||
|
||
def render(i: int) -> Text:
|
||
return self._match_row(rows[i], i == self._index, depths, name_w, key_w)
|
||
else:
|
||
heading = Text("Links", style="accent")
|
||
heading.append(" · strongest observed first · Enter walks", style="muted")
|
||
out.append(render_to_ansi(heading, width, no_wrap=True))
|
||
if not rows:
|
||
note = Text(
|
||
"no observed links from here — type to find another node", style="muted"
|
||
)
|
||
out.append(render_to_ansi(note, width))
|
||
return out
|
||
# Rows are the authority on *which* links this list holds and in what order
|
||
# (:meth:`_rows`); the link table is only looked up *through* them. Rendering
|
||
# straight out of the table was the bug: it still carried the node the walk came
|
||
# from, so every index past it named one row and drew another, and the highlight
|
||
# sat one link away from the one the canvas was lighting. Onward counts are
|
||
# still gathered over the whole table — that memo is per focus, not per paint,
|
||
# and a narrowed view must not be what fills it.
|
||
by_other = dict(self._links_of(self._focus))
|
||
onward = self._onward_counts(self._links_of(self._focus))
|
||
name_w, key_w = self._lane_widths(width, rows, _LINK_ROW_FIXED)
|
||
|
||
def render(i: int) -> Text:
|
||
other = rows[i]
|
||
return self._link_row(
|
||
other, by_other[other], i == self._index,
|
||
onward.get(other, 0), name_w, key_w,
|
||
)
|
||
|
||
top, count = self._list.fit(len(rows), win, self._index)
|
||
if top > 0:
|
||
out.append(render_to_ansi(ListWindow.marker(top, "above"), width))
|
||
for i in range(top, top + count):
|
||
out.append(render_to_ansi(render(i), width, no_wrap=True))
|
||
below = len(rows) - top - count
|
||
if below > 0:
|
||
out.append(render_to_ansi(ListWindow.marker(below, "below"), width))
|
||
return out
|
||
|
||
def _lane_widths(self, width: int, nodes: list[str], fixed: int) -> tuple[int, int]:
|
||
"""The list's ``(name, key)`` lane widths — the name lane first, the key filling in.
|
||
|
||
The name lane sizes to the widest name the rows carry (a short-name list stays
|
||
tight); the key lane takes whatever's left, capped at the whole 12-hex id. When the
|
||
two together outrun the row, the *key* gives ground first — shrinking on a byte
|
||
boundary down to a floor that still shows its whole lit hash plus a ``…`` — and only
|
||
once the key is at that floor does a long name start losing letters. So the name is
|
||
the last thing truncated and the hash never is (the user reads names, and addresses
|
||
by hash).
|
||
"""
|
||
hash_w = max(2, min(_LIST_HASH_W, self._prefix_bytes * 2))
|
||
# The tightest key lane that still shows the whole hash: the hash plus a "…". An odd
|
||
# width, so :func:`highlighted_hash` keeps an even hash_w digits with no wasted cell;
|
||
# when the hash already fills the id there's nothing to drop, so the floor is the
|
||
# full width.
|
||
key_floor = _LIST_HASH_W if hash_w >= _LIST_HASH_W else hash_w + 1
|
||
avail = width - fixed
|
||
widest = max((cell_len(self._label(n)) for n in nodes), default=_LIST_NAME_MIN)
|
||
name_w = max(_LIST_NAME_MIN, min(widest, avail - key_floor))
|
||
key_w = max(key_floor, min(_LIST_HASH_W, avail - name_w))
|
||
return name_w, key_w
|
||
|
||
def _onward_counts(self, pairs: list[tuple[str, Link]]) -> dict[str, int]:
|
||
"""How many links continue from each neighbour, the one back here excluded.
|
||
|
||
Memoized per focus (the counts depend only on the frozen graph and whose
|
||
neighbours are being listed) — this was quadratic per repaint: every
|
||
neighbour re-walked a fresh copy of the whole link table.
|
||
"""
|
||
focus = self._focus
|
||
memoized = self._onward_memo.get(focus)
|
||
if memoized is not None:
|
||
return memoized
|
||
counts: dict[str, int] = {}
|
||
for other, _link in pairs:
|
||
# A link off the neighbour "continues onward" unless its far end is here:
|
||
# one endpoint is the neighbour itself, so only the far endpoint can be us.
|
||
counts[other] = sum(
|
||
1 for far, _l in self._links_of(other) if far != focus
|
||
)
|
||
self._onward_memo[focus] = counts
|
||
return counts
|
||
|
||
def _link_row(
|
||
self, other: str, link: Link, selected: bool, onward: int, name_w: int, key_w: int
|
||
) -> Text:
|
||
"""One neighbour row: glyph, name, hash, SNR + bar, evidence, onward count."""
|
||
glyph, glyph_style = self._glyph(other)
|
||
row = Text()
|
||
row.append("❯ " if selected else " ", style="cursor" if selected else "")
|
||
row.append(glyph, style=glyph_style)
|
||
row.append(" ")
|
||
name_style_ = self._list_name_style(other)
|
||
row.append(fit_cells(self._label(other), name_w), style=name_style_)
|
||
row.append(" ")
|
||
row.append_text(highlighted_hash(other, self._prefix_bytes, width=key_w,
|
||
known=self._known(other)))
|
||
row.append(" ")
|
||
snr = link.median_snr
|
||
if snr is not None:
|
||
row.append(f"{snr:+5.1f}", style=snr_style(snr))
|
||
else:
|
||
row.append(" —", style="muted")
|
||
row.append(" ")
|
||
row.append_text(snr_bar(snr, width=4))
|
||
row.append(f" {min(link.samples, 999):>3}×", style="muted")
|
||
tags = "".join(_SOURCE_TAGS[s] for s in sorted(link.sources & _SOURCE_TAGS.keys()))
|
||
row.append(f" {tags:<4}", style="faint")
|
||
age = _format_age(
|
||
max(0.0, (utcnow() - link.last_seen).total_seconds())
|
||
if link.last_seen is not None and getattr(link.last_seen, "tzinfo", None)
|
||
else None
|
||
)
|
||
row.append(f"{age:>5}", style="muted")
|
||
if onward:
|
||
row.append(f" ⋯ {onward}", style="faint")
|
||
if selected:
|
||
row.style = "cursor"
|
||
return row
|
||
|
||
def _match_row(
|
||
self, node: str, selected: bool, depths: dict[str, int], name_w: int, key_w: int
|
||
) -> Text:
|
||
"""One find match: glyph, name, hash, and how far out it sits."""
|
||
glyph, glyph_style = self._glyph(node)
|
||
row = Text()
|
||
row.append("❯ " if selected else " ", style="cursor" if selected else "")
|
||
row.append(glyph, style=glyph_style)
|
||
row.append(" ")
|
||
row.append(fit_cells(self._label(node), name_w), style=self._list_name_style(node))
|
||
row.append(" ")
|
||
row.append_text(highlighted_hash(node, self._prefix_bytes, width=key_w,
|
||
known=self._known(node)))
|
||
row.append(" ")
|
||
if node == self._topo.self_id:
|
||
row.append("this device", style="muted")
|
||
elif node not in depths:
|
||
row.append("island", style="warn")
|
||
else:
|
||
ring = depths[node]
|
||
row.append(f"{ring} hop{'s' if ring != 1 else ''} out", style="muted")
|
||
if selected:
|
||
row.style = "cursor"
|
||
return row
|
||
|
||
def _list_name_style(self, node: str) -> str:
|
||
"""The list's name colour: the app-wide palette hue (hash-derived), us in pure white."""
|
||
if node == self._topo.self_id:
|
||
return "you"
|
||
label = self._label(node)
|
||
if label == node[:8]: # a bare hash is not a name — colour is the name signal
|
||
return "node.unknown"
|
||
return name_style(label, node)
|
||
|
||
def _known(self, node: str) -> bool:
|
||
"""Whether the node is identified — named, or us — so its hash may carry a hue.
|
||
|
||
The gate every hash lane here passes to :func:`~meshterm.ui.widgets.highlighted_hash`:
|
||
an unknown node's hash reads grey whole, matching its ``node.unknown`` name lane
|
||
and its ``○`` ring on the canvas.
|
||
"""
|
||
return self._list_name_style(node) != "node.unknown"
|
||
|
||
def _marker_rgb(self, node: str) -> RGB:
|
||
"""The marker colour for a node: the hue its *type* wears everywhere (JP, 2026-08-09).
|
||
|
||
The walk used to colour markers by identity — the key-derived per-node hue — with
|
||
the glyph shape left to carry the type alone. That put this one canvas at odds with
|
||
every other place a typed node is pinned (the map, the route graph, a contact row),
|
||
where the mark's colour *is* the type and is chosen deliberately per platform
|
||
through the theme's ``type.*`` (a naive downsample greys the repeater's violet).
|
||
Identity has its own lane here and always did: the **name** beside the marker, in
|
||
the key hue (:meth:`_label_rgb`). Shape and colour now agree, and the legend under
|
||
the canvas can key both.
|
||
"""
|
||
return mark_rgb(self._glyph(node)[1])
|
||
|
||
def _label_rgb(self, node: str) -> RGB:
|
||
"""The label colour for a node on the canvas: its name hue, ours white, a bare hash grey.
|
||
|
||
The RGB counterpart of :meth:`_list_name_style`: a named node's key hue, our own
|
||
node white, and a node known only by a bare hash the unknown-node grey (a key
|
||
standing in as a name is never itself coloured, so the marker carries the identity
|
||
and the hash-label stays grey).
|
||
"""
|
||
style = self._list_name_style(node)
|
||
if style == "you":
|
||
return (255, 255, 255)
|
||
if "#" not in style: # a theme name (node.unknown) — the platform picks the shade
|
||
return mark_rgb(style)
|
||
return parse_hex(style.rsplit("#", 1)[-1])
|
||
|
||
def _glyph_style(self, node: str) -> str:
|
||
"""The Rich style a node's type glyph takes in body text — its type colour.
|
||
|
||
The text-side companion of :meth:`_marker_rgb`: the same ``type.*`` entry (or the
|
||
star's own yellow), so the focus line's leading glyph, the list rows' glyphs and
|
||
the canvas markers are one mark drawn three ways.
|
||
"""
|
||
return self._glyph(node)[1]
|
||
|
||
def _empty_state(self, width: int) -> list[str]:
|
||
"""A friendly explanation while the evidence graph is still empty."""
|
||
lines = [
|
||
Text(),
|
||
Text("This walk has no evidence to draw yet.", style="accent"),
|
||
Text(),
|
||
Text("Topology accrues passively as the mesh talks:", style="muted"),
|
||
Text(" · every successful trace maps each link it walked", style="muted"),
|
||
Text(" · firmware routes and overheard relay chains fill in more", style="muted"),
|
||
Text(" · a repeater's neighbour table adds its own vantage point", style="muted"),
|
||
Text(),
|
||
Text("Run a trace, or just leave MeshTerm listening.", style="muted"),
|
||
]
|
||
return [render_to_ansi(t, width, no_wrap=True) for t in lines]
|
||
|
||
def _glyph(self, node: str) -> tuple[str, str]:
|
||
"""A node's mark: the type glyph and the colour that type wears app-wide.
|
||
|
||
The shared marks (:data:`~meshterm.ui.widgets._NODE_GLYPHS`) and the shared
|
||
``type.*`` colours behind them, so a node is pinned here in exactly the glyph and
|
||
hue the map, the route graph and the contact list pin it in — ``▲`` violet for a
|
||
repeater, ``■`` for a room, ``◉`` for a sensor, ``●`` pink for a plain node, our
|
||
own the yellow ``★``, and a node we have heard of but never identified the grey
|
||
``○``. Colour and shape say the same thing, which is what makes the legend below
|
||
the canvas a key rather than a coincidence.
|
||
|
||
Returns:
|
||
``(glyph, colour)`` — the colour in whichever encoding
|
||
:func:`~meshterm.ui.theme.mark_rgb` takes (a literal hex, or a theme name where
|
||
the platform must pick its own slot).
|
||
"""
|
||
if node == self._topo.self_id:
|
||
return _SELF
|
||
contact = self._contacts.get(node)
|
||
if contact is None:
|
||
return _UNKNOWN
|
||
return _NODE_GLYPHS.get(contact.node_type, _DEFAULT_GLYPH)
|
||
|
||
def _label(self, node: str) -> str:
|
||
"""A node's display name: its own name for us, contact name, or short hash."""
|
||
if node == self._topo.self_id:
|
||
return self._self_label
|
||
return self._topo.display_name(node) or node[:8]
|
||
|
||
|
||
async def open_walk(ctx: "AppContext") -> None:
|
||
"""Build the evidence graph and run the full-screen mesh walk until dismissed.
|
||
|
||
Contacts and our own identity come from the device when one is reachable
|
||
(best-effort — the stored evidence draws fine without them, just with hashes for
|
||
names); the graph itself comes entirely from the repository, snapshotted once when
|
||
the screen opens. No transmissions, ever.
|
||
|
||
Args:
|
||
ctx: The shared application context (must be running the interactive TUI).
|
||
|
||
Raises:
|
||
RuntimeError: If called outside the interactive menu (no full-screen session).
|
||
"""
|
||
from ..services.topology import build_topology
|
||
from .surface import TuiUi
|
||
from .timemachine_screen import _routing_prefix_bytes
|
||
|
||
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
|
||
raise RuntimeError("the mesh walk is only available in the menu")
|
||
session = ctx.ui.session
|
||
|
||
contacts: list[Contact] = []
|
||
self_label = "you"
|
||
self_hash: Optional[str] = None
|
||
try:
|
||
if ctx.is_connected or ctx.settings.connect_on_start:
|
||
contacts = await ctx.devstate.contacts()
|
||
info = await ctx.devstate.self_info()
|
||
self_label = str(info.get("name") or "you")
|
||
self_hash = str(info.get("public_key") or "") or None
|
||
except Exception: # noqa: BLE001 - names are a nicety; the graph renders without them
|
||
contacts = []
|
||
prefix_bytes = await _routing_prefix_bytes(ctx)
|
||
|
||
topo = build_topology(
|
||
self_id=self_hash or "local",
|
||
contacts=contacts,
|
||
trace_paths=ctx.repo.trace_paths(),
|
||
packet_paths=ctx.repo.packet_paths(),
|
||
neighbour_links=ctx.repo.neighbour_links(),
|
||
)
|
||
by_id: dict[str, Contact] = {}
|
||
for contact in contacts:
|
||
canonical = topo.canonical(contact.public_key or contact.key_prefix)
|
||
if canonical:
|
||
by_id[canonical] = contact
|
||
|
||
screen = WalkScreen(
|
||
session=session,
|
||
topo=topo,
|
||
contacts=by_id,
|
||
self_label=self_label,
|
||
prefix_bytes=prefix_bytes,
|
||
)
|
||
try:
|
||
await session.run_screen(screen)
|
||
finally:
|
||
# The same clean-slate repaint as the map: braille fallback glyphs may have
|
||
# smeared cells prompt_toolkit's differential paint will never rewrite.
|
||
session.request_full_repaint()
|