Files
MeshTerm/meshterm/ui/walk_screen.py
T
Jean-Pierre MartineauandClaude Opus 5 b6f3218af9 Mesh walk: just the name
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>
2026-08-30 02:59:01 -04:00

1449 lines
72 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The 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()