Files
Jean-Pierre MartineauandClaude Opus 5.5 eeafbb18f6 Node marks are white, and the border is darker
Every node-type mark is white now: ● ▲ ■ ◉ were pink, violet, white,
and orange, and the shape already says the type. Each keeps its own
type.* style, so one can take a colour back in one place; the unknown ○
keeps its grey. On the map a crowd in one cell used to glow toward
white, which a white marker cannot do, so that signal is gone.

The border grey goes down to #7c7c7c, which JP picked on a ladder. That
sits almost on faint, so the title bar's live clip arrow is now lit like
the title instead of taking the border's grey. On the PicoCalc the
border is the dark grey slot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 00:30:12 -04:00

1530 lines
79 KiB
Python
Raw Permalink 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.
# SPDX-License-Identifier: Apache-2.0
"""The Mesh walk: go through the observed shape of the mesh, one node at a time.
This screen is the interactive form of the ``walk`` tool. The trace path composer
collects each part of the topology that we received into one evidence graph
(:mod:`~meshterm.services.topology`): trace walks, firmware routes, relay chains from the
RX log, and the neighbour tables of repeaters. This screen lets the user explore that
graph. Nothing here transmits: the walk shows only what the radio has already heard.
The walk does not plot the full mesh at one time, because a large graph then looks like
a tangle. Instead, the walk keeps one node *in focus* (at the start, our node) and shows
only its direct neighbours:
* The **canvas** takes most of the screen, so that the shape stays easy to read. It puts
the focus at the far **west**, with its name to its right. Then it spreads a fan of the
strongest neighbours to the east across the width. The fan is sparse on purpose. The
name of each neighbour is immediately to the right of its marker. Each marker is the
**node-type mark** of the app, with its glyph and its colour: the same mark that the
map and the route graph use for that node. The *identity* of the node is in the name
next to the marker, in the hue of the node. Edges are braille lines. The median SNR of
the link sets the colour of an edge (green → amber → red, and grey for a link with no
reading), and the age of the evidence makes it fainter. Each edge starts at the *right
end of the focus name*, so that a line never crosses a label. The fan is on a grid of
rows: one marker on each row, a blank row between two markers, and a blank row above
and below when the canvas has the space. The canvas draws only as many neighbours as
these rows can hold. The remaining weaker neighbours collapse into one ``…`` marker.
They are ranked by observed strength: the SNR, the sample count, and the recency
together. Thus a strong link that is very old does not go above a newer one. When the
list highlights one of the collapsed neighbours, the ``…`` marker stands in for it: it
shows the mark of that node, its name, and a grey ``(2/17)`` for its position among
them. The canvas draws only the ways *onward*. The node that the walk came from is
behind you, and ⌫ takes you back to it.
* The **link list** below the canvas shows each way onward as a row that you can
select. The strongest observed link is first. Thus row 0, where the highlight starts,
is always the strongest link from this node. Each row has the type glyph, the name,
the hash, the SNR with a quality bar, the evidence for the link (samples, sources, and
age), and the number of links that continue onward from that node. On the canvas, the
label of the highlighted row shows in white. The list scrolls *inside* the screen: the
canvas, the legend, and the heading do not move, and faint ``↑/↓ n more`` markers are
at the top and bottom of the list window. ↑↓ move one row, and PgUp/PgDn move one list
window.
**Enter walks**: the highlighted neighbour becomes the new focus, and the breadcrumb trail
at the top becomes longer. The trail is a :mod:`~meshterm.ui.pathline` path line: powerline
chips where the terminal can draw them, or ``you › Hilltop-Repeater › …`` where it cannot.
Each name is in the hue of its node. **⌫ steps back** along the trail. If you walk to a
node that is already on the trail, the stack is cut back to the first position of that
node. Thus the loop that you walked to come back there is removed, not stored. The trail
never wraps. It is one line that the user reads through a window. It is drawn complete
while it fits. If it does not fit, it is *cropped* at each edge where the walk
continues: the chip breaks on its own half block, the same as each path row in the app
that is too long for its lane. At rest, the window is at the tail. Thus the focus and the
last steps are visible, and the crop removes the start of the walk. **←→ slide the
window**, so that you can read back over a long walk. **^L** puts the focus on our node
again. **Typing finds**: a filter over all the nodes in the graph, islands also. While you
type, it also narrows the fan on the canvas to the neighbours that match, and the focus
stays. Enter teleports the focus to the highlighted match. The trail then starts again
there, because the walk did not cross the gap. The first Esc clears the find, and the
second Esc leaves the screen.
"""
from __future__ import annotations
import math
from collections import deque
from datetime import datetime
from itertools import pairwise
from typing import TYPE_CHECKING
from rich.cells import cell_len
from rich.text import Text
from ..core.models import NODE_TYPE_REPEATER, Contact, utcnow
from ..platforms import get_platform
from ..services.topology import Link, MeshTopology
from .mapcanvas import RGB, MapCanvas, parse_hex
from .marks import SELF_MARK, UNKNOWN_MARK
from .menus import fit_cells
from .pathline import ELIDE_HEAD, ELIDE_TAIL, SELF_GLYPH, PathHop, PathLine, cut_mark
from .theme import mark_rgb, name_style, snr_style
from .trace_screen import snr_bar
from .tui.render import crop_cells, query_line, render_to_ansi
from .tui.screen import ListWindow, Screen
from .widgets import DEFAULT_GLYPH, NODE_GLYPHS, format_age, highlighted_hash, short_frame
if TYPE_CHECKING:
from ..context import AppContext
#: The part of the width east of the focus name that the fan keeps for its reach, however
#: long the names are that it must make space for. Below this part, the graph does not
#: look like a fan (the markers pile onto the focus). Thus, past this limit, the labels
#: are clipped instead (refer to :meth:`WalkScreen._place_neighbours`).
_MIN_FAN_REACH = 1 / 3
#: The minimum height of the canvas in character rows. Below this height, the user cannot
#: see the shape of the fan.
_CANVAS_MIN_H = 6
#: The rows that the link list always keeps for itself under the canvas, however tall the
#: graph wants to be. A list in a list window must have at least a few rows to scroll in.
_LIST_MIN_ROWS = 3
#: The maximum width of a label on the canvas before it ellipsizes. This limit is large,
#: and most real node names fit it completely. It is only an upper limit: each label is
#: *also* clamped to the free cells between its marker and the edge of the canvas (refer to
#: :meth:`_label_right`, :meth:`_focus_anchor`, :meth:`_place_label`). Thus a name loses
#: letters only when it really does not fit, not because of a fixed short limit. The fan
#: placement gives most markers much more space than the old fixed limit did.
_LABEL_W = 22
#: The label limit of the fan. It is higher than the limit of the focus, and it is the
#: longest name for which a marker can move west (JP, 2026-08-09). The user reads the names
#: in the fan, so the fan gets the space. The focus keeps the smaller :data:`_LABEL_W`,
#: because each cell that it uses pushes the full fan east. Also, the line below the
#: canvas shows the full name of the focus. 32 is the limit of the protocol for a node
#: name, so a name that fits the mesh fits here.
_FAN_LABEL_W = 32
#: The name length for which the *default* reach of the fan is sized. A marker with a
#: longer name moves west from there, one cell for each extra cell of the name. A marker
#: with a shorter name does not move. Thus one long name never pulls the full fan in.
_FAN_BASE_LABEL_W = 12
#: The angular reach of the fan on each side of due east, in radians. This angle sets the
#: *vertical* spread of the fan (the marker rows that the leaves spread across).
#: :data:`_FAN_X_FLATTEN` makes the *horizontal* reach flatter than this angle. Thus the
#: height of a leaf and its distance to the east are independent. The full fan stays east
#: of the focus (neighbours to the right, labels to the right). A smaller neighbourhood
#: uses a proportionally smaller part of the arc. Thus two nodes are never at the two ends
#: of the arc with nothing between them.
_FAN_HALF_ANGLE = math.radians(72)
#: How much flatter the horizontal reach of the fan is than its vertical spread. The full
#: fan angle of a leaf sets its *height*. Its *east reach* uses only this fraction of that
#: angle. Thus the leaves at the rim (top and bottom) curve back from due east only a
#: little, and do not curve in toward the focus on a true circle. The fan then spreads
#: across the width, and it does not bulge in the middle with empty corners. ``1.0`` gives
#: the old circular arc again, and a lower value makes it flatter. The setback still
#: changes with the angular reach of the fan, so a small fan near due east moves back
#: almost not at all.
_FAN_X_FLATTEN = 0.55
#: The fan size for which the canvas gives up its outer blank rows. Above this size, the
#: canvas keeps a blank row at the top and at the bottom. Below it, these rows go to nodes
#: instead. Refer to :meth:`WalkScreen._fan_capacity`.
_FAN_MIN_SLOTS = 7
#: The sentinel key for the collapsed marker of the weaker links, in the map of placed
#: nodes. NUL can never be the same as a canonical id (canonical ids are hex).
_MORE = "\x00more"
#: The separator between the hops of the breadcrumb trail (``›``, not the ``→`` of the
#: rest of the app). Each end that has more walk outside the viewport shows the cut mark
#: of the app: a chip that breaks on its own fill, or the faint ``…`` where the line is
#: drawn in arrows (refer to :meth:`WalkScreen._trail_text`).
_TRAIL_SEP = " › "
#: The cells by which one ←/→ press slides the breadcrumb. This is the same step as each
#: other path row in a window in the app (the routes on the node page, the Message paths
#: lanes, the ``hscroll`` of the select list), because this is the same type of window:
#: one line, cropped, that the user reads one lane at a time. The old fit moved one hop for
#: each press, when the overflow put whole hops behind a ``⋯``. A cropped line has no hop
#: boundaries to step by. When the step is the unit of the crop, the slide looks like a
#: slide.
_HSCROLL_STEP = 8
#: The maximum number of find matches that the list shows (the filter narrows it fast).
_MAX_MATCHES = 10
#: The minimum width of the name lane in the list of neighbours or matches. The lane width
#: comes from its content, and it can grow to the width that the fixed lanes leave (refer
#: to :meth:`WalkScreen._lane_widths`). Thus a long name shows as much as the row has space
#: for.
_LIST_NAME_MIN = 10
#: The *maximum* width of the key lane: the full canonical id of 12 hex digits, with the
#: addressed prefix lit in the hue of the node (refer to :func:`highlighted_hash`). The
#: lane becomes narrower from here on a byte boundary, to give a long name more space. But
#: it never becomes narrower than its lit hash (refer to :meth:`WalkScreen._lane_widths`):
#: the name loses letters before the hash does.
_LIST_HASH_W = 12
#: The cells that a link row uses *outside* its two flexible lanes (name and key), so that
#: these two lanes can use the remaining cells: pointer (2) + type glyph and its space (2)
#: + the space between name and key (1) + gap (2) + SNR (5) + space (1) + quality bar (4)
#: + samples (5) + source tags (5) + age (5) + a reserve for the ⋯ count at the end (8).
_LINK_ROW_FIXED = 2 + 2 + 1 + 2 + 5 + 1 + 4 + 5 + 5 + 5 + 8
#: The same, for a find-match row. This row ends with a short note of the distance, not
#: with the SNR evidence: pointer (2) + glyph and space (2) + the space between name and
#: key (1) + gap (2) + a reserve for the distance (12, for ``this device`` / ``N hops out``).
_MATCH_ROW_FIXED = 2 + 2 + 1 + 2 + 12
#: The anchors from SNR (dB) to edge colour. The colour between two anchors is a linear
#: interpolation, and it is clamped at the ends. These are the red, amber, and green of the
#: SNR styles of the app, so that 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)),
)
#: The edge colour for a link with no SNR reading (for example, a link known only from a
#: route).
_NO_READING: RGB = (115, 115, 115)
#: One-letter tags for the types of evidence for a link, the same as in the path composer:
#: T(race), R(oute), P(acket log), N(eighbour table).
_SOURCE_TAGS = {"trace": "T", "route": "R", "packet": "P", "neighbour": "N"}
def _snr_rgb(snr: float | None) -> RGB:
"""The edge colour for the median SNR of a link (refer to :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 pairwise(_SNR_STOPS) 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, strict=True)) # type: ignore[return-value]
def _scaled(rgb: RGB, factor: float) -> RGB:
"""``rgb`` made dimmer (or a little brighter) by ``factor``, clamped to the byte range."""
return tuple(max(0, min(255, round(c * factor))) for c in rgb) # type: ignore[return-value]
def _freshness(last_seen: datetime | None, now: datetime) -> float:
"""How bright a link is for the age of its evidence: 1.0 new → 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 walk: a canvas of the focus neighbourhood over a link list."""
floating = False
@property
def picocalc_lyra_lane(self):
"""``You`` on the F3 of this screen, and the shared pager with its two ends on F4/F5.
``You`` is not an end of the link list. It removes the full trail and puts the
focus on our node again. Thus it was never correct behind the pager, where it had
to stand in for *Top* and leave *Bottom* blank to keep up the pretence. Instead, it
takes a slot of this screen (JP, 2026-08-09), because F1-F3 are for this purpose.
The Shift bank of the pager then has the same meaning here as in all other places:
the two ends of the list that this screen scrolls. On a keyboard, ^L does the same
action. The map uses the same chord for our node.
The two navigation slots must have rows to move through, and a leaf node in a
sparse graph may not have them. ``You`` must have a place to come back *from* (a
walked trail, or a find that narrows the list). It dims when the focus is already
our node.
"""
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 - a TuiSession, imported late to prevent an import cycle
topo: MeshTopology,
contacts: dict[str, Contact],
self_label: str,
prefix_bytes: int = 0,
) -> None:
"""Create the walk over a snapshot of a built topology.
Args:
session: The running TUI session (for paints).
topo: The evidence graph to walk.
contacts: The contacts by canonical id, for glyphs, names, and ages.
self_label: The display name for our node (its mesh name when it is known).
prefix_bytes: The width of the path hash to light in the hash lane (0 = none).
"""
super().__init__()
# The name of the screen, and nothing more (JP, 2026-08-30). The title once had the
# focus and the size of the graph:
# ``Mesh walk — Hilltop-Repeater · 42 nodes · 61 links``. That title was longer than
# a title bar of 53 columns, and each atom of it was already on the screen below.
# The trail ends on the focus. The line under the trail shows the name of that node
# in colour, with its hash and its ring. The canvas and the link list exist to show
# the size of the neighbourhood. The title is set one time here, not composed at
# each render. Thus the top row never changes while 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. A walk appends,
#: ⌫ pops, ^L resets the trail to our node, and a find teleport starts it again.
self._trail: list[str] = [topo.self_id]
#: The cells by which the breadcrumb line is scrolled off its *tail* end (refer to
#: :meth:`_trail_text`). 0 is the rest state (the focus at the right edge). Each
#: change to the trail sets it back to 0.
self._trail_scroll = 0
#: ``((width, trail) → the furthest that can scroll)``, set at render (refer to
#: :meth:`_trail_max_scroll`). Thus a key press can clamp itself without a width.
self._trail_fit: tuple[tuple, int] | None = None
#: The width of the last render of the trail, for that same clamp.
self._trail_width = 0
#: The index of the highlighted row in the current list (neighbours or matches).
self._index = 0
#: The live find-as-you-type filter ("" = off). It searches all the known nodes.
self._filter = ""
self._needs_scrub = True # the scrub of the braille smear, the same as on the map
#: The list window of the link list (the list scrolls, the screen does not). Its
#: settled capacity is the step by which a PgUp/PgDn moves the highlight.
self._list = ListWindow()
# Caches of values from the graph. MeshTerm takes a snapshot of the topology one
# time when the screen opens (to see new evidence, open the screen again). Thus
# each value that comes only from the snapshot (neighbour lists, the node set, BFS
# depths, the counts of onward links) is calculated one time for each key, not at
# each paint. Nothing ever makes these caches invalid: they exist exactly as long
# as the frozen graph that they describe.
self._links_of_memo: dict[str, list[tuple[str, Link]]] = {}
self._all_nodes_memo: set[str] | None = None
self._hops_out_memo: dict[str, int] | None = None
self._onward_memo: dict[str, dict[str, int]] = {}
# --- state -------------------------------------------------------------------
@property
def _focus(self) -> str:
"""The node that is now in focus (the last step of the trail)."""
return self._trail[-1]
@property
def _came_from(self) -> str | None:
"""The node that the trail came from, or ``None`` at the start of the trail."""
return self._trail[-2] if len(self._trail) > 1 else None
def _links_of(self, node: str) -> list[tuple[str, Link]]:
"""``(other, link)`` for each link from ``node``, the strongest evidence first.
The result is cached for each node over the frozen graph. The sort order is set
at the first request: the strength decreases over hours, much slower than the
time that 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]:
"""All the nodes in the graph, and our node (a walk can start there, also 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]:
"""The BFS hop distance from our node over the evidence links.
A node with no path to our node is not in the result. Such nodes are the islands.
Where a distance usually shows, the screen marks them as islands.
"""
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).
This is the only predicate for the two things that the query narrows: the list of
teleport candidates (:meth:`_matches`, over the full graph) and the fan on the
canvas (:meth:`_canvas_lines`, over the neighbours of the focus). When there is no
filter, all nodes match. Thus a caller does not have to check for a filter 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]:
"""The nodes that the find filter matches: the 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 node ids that the list now shows and that the user can select.
These are the matches, or the ways onward. The neighbour rows are
:meth:`_fan_nodes`: the same set that the canvas draws, in the same order (the
strongest first). Thus row 0, where the highlight goes at each reset, is the
strongest link from this node.
"""
if self._filter:
return self._matches()
return self._fan_nodes()
# --- input -------------------------------------------------------------------
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The keys of the walk, or the live find query while the user types one.
All the atoms fit the limit of 72 cells only because two of them alternate.
``←→ trail`` shows exactly while the breadcrumb has more walk than width. (A
keyboard key that does nothing is never shown: this is an old rule of the hint
line, and of the F-key lane after it.) It takes the place of ``type to find``. When a
walk is long enough to overflow the trail, the scroll is the key that the user
does not know. But typing shows itself at the first letter, because the query
then replaces this full line. The find teaches itself, and the scroll could not.
"""
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", "^L 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, step back, find, or close.
Home/End go to the ends of the list here, the same as on each other screen that
scrolls. The jump back to our node is ``locate`` (^L, and F3 on the lane), which is
not a position in this list. Refer to :attr:`picocalc_lyra_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:
# Both ends clamp and do not wrap. The list scrolls inside a list window (refer
# to ListWindow). If the highlight jumps from one end to the other, it takes the
# list window with it. That is the one move that looks like a change of the
# screen under you.
self._index = max(0, self._index - 1)
elif action == "down" and rows:
self._index = min(len(rows) - 1, self._index + 1)
elif action == "pageup" and rows:
# The highlight moves by the height of one list window. The list window follows
# the highlight. Thus, if only the list window moves, it goes back immediately.
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, and the list does not: the breadcrumb is the only line
# here with more content than width. The scroll is clamped against the width of
# the last paint. Thus, when the user holds ←, the line stops at the head, and
# the extra presses are not kept for later. (The limit is 0 until the trail
# overflows, so the keyboard key does nothing on a short walk.)
self._trail_scroll = min(
self._trail_scroll + _HSCROLL_STEP,
self._trail_max_scroll(self._trail_width),
)
elif action == "right":
self._trail_scroll = max(0, self._trail_scroll - _HSCROLL_STEP)
elif action == "locate":
# ^L (and F3): stop the walk, instead of a move inside it. The trail goes back
# to only our node, and a find that narrows the list is also removed.
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 start the filter with a space
self._filter += data
self._index = 0
elif action == "space" and self._filter:
self._filter += " " # node names have spaces. A space is useful only mid-query
self._needs_scrub = True
self._session.invalidate()
def _walk(self, rows: list[str]) -> None:
"""Put the focus on 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 starts the trail again at the target. The walk did not cross the
# gap. If the trail shows that it did, ⌫ goes back along a path that the user
# never took.
self._trail = [target]
self._filter = ""
elif target in self._trail:
# A walk to a node that is already on the trail (a step back through the west
# node, or a loop round to an earlier node) cuts the stack back to the first
# position of that node. We remove the loop that we walked to come back here,
# and we do not store the round trip. The removal of the loop is intentional.
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 important part. Make it visible again
# --- smear scrub (the same problem with fallback glyphs as on the map) ---------
def consume_edge_scrub(self) -> int:
"""The columns at the right edge to paint again after a paint (refer to the map)."""
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 link list in its list window.
The layout of the body fits the viewport of the frame exactly. The canvas takes
most of the rows (a slightly smaller part on tall terminals). The chrome around it
does not move. The remaining rows are the list window of the link list. 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 # the frame stores it before this render
header = self._header_lines(width, depths)
# On a short frame, the legend and the blank line under it are removed, because
# there the rows go to the list (refer to widgets.short_frame). The heading of the
# list stays in both cases.
keyed = not short_frame(viewport)
chrome = len(header) + (3 if keyed else 1) + self._query_row() # (+ the 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))
if keyed:
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 uses a list row to echo the find query (1) or not (0).
This occurs only where the footer is not drawn
(:attr:`~meshterm.platforms.Platform.footer_fkeys`). There, the hint line that has
the query never gets to the screen. Without this row, the list narrows to the
matches and shows no sign of the text that narrowed it. The row is immediately
above the ``Matches`` heading: above what it narrows, as on each other
find-as-you-type screen. The list gives the line for it, not the canvas.
"""
return 1 if self._filter and get_platform().footer_fkeys else 0
def _canvas_height(self, viewport: int, chrome: int) -> int:
"""The rows for the canvas: most of the screen, but fewer where more do not help.
The part starts at approximately 62% of the viewport, and it decreases toward half
on tall terminals. When the fan is easy to read, a very large graph area adds
little, but more rows still help the list. A sparse neighbourhood sets a lower
limit: a link between two nodes does not need half a screen of empty space. 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 identity line of the focus node.
The trail always shows, also at the root, where it is only our node. Thus the
breadcrumb is always there, and the header keeps the same height before and after
you walk. Without it, the full body moves up one row at your 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, scrolls with ← →, never wraps.
The walk *is* a path: our node, then each node that the walk stepped through, and
at the end the focus. Thus it renders through
:class:`~meshterm.ui.pathline.PathLine`, the same as each other sequence of hops in
the app: powerline chips where the terminal can draw them, and the ``›`` arrows of
the trail where it cannot. Each hop shows in the hue of its node, which comes from
its key. Our node is the white ``you``. A node known only by a bare hash takes the
grey of an unknown node (``node.unknown``), because colour is only for nodes that
have a key. Thus the trail and the rows below it agree about which node is which.
The line never wraps, and nothing in it is ever elided. It is composed one time at
its natural length, and then the user **reads it through a window**, the same as
each other path row in the app that is too long for its lane (the routes on the
node page, the Message paths lanes, a trophy row under ``←→``). While all of it
fits, it shows complete: at the left edge, and it starts on its rounded head chip,
as it does in a lane with free cells. When it does not fit, it is cropped, and
**each edge where the walk continues shows the cut mark**
(:func:`~meshterm.ui.pathline.cut_mark`): a chip that breaks on the half block in
its own fill, or the faint ``…`` where the trail is drawn in arrows. The user
loses only the cells for which the lane had no space, never the full hop that
these cells were part of (JP, 2026-09-05). There is no ``⋯`` here on either side,
because nothing is elided.
Only one thing sets the edge where the line starts: **whether the start of the
walk is on the line.** A line that shows its first hop is at the left, because a
path that starts at its start belongs there. A line that is cropped at the head is
at the right, with the break against the edge where it continues. A crop is
exactly the width, so the line touches the edge because of its construction, not
because of padding. At rest, the window is at the tail. Thus the user sees the
focus and the last steps to it first.
← and → then **slide the window** (JP, 2026-08-09), by :data:`_HSCROLL_STEP` cells
for each press. The cropped head is a real part of the walk, and before this
change the user could not read a long walk at all. The marks are chrome *inside*
the lane, not extra width. Each mark takes one cell from the window. Thus the crop
is measured only when the two marks are known, or the line draws one cell past the
row. The slide stops when the head becomes visible, so the line does not move off
the edge. Each change to the trail resets the slide: a walk, a step back, a
teleport, and ^L all end with the focus visible, because the user reads the next
move from there.
"""
self._trail_width = width
full = PathLine(
[self._trail_hop(node) for node in self._trail], separator=_TRAIL_SEP
).text()
total = full.cell_len
# The scroll is clamped before the fit is read. Thus, when a line fits its width
# again (a wider terminal, a step back), no slide stays behind a complete line.
self._trail_scroll = max(0, min(self._trail_scroll, self._trail_max_scroll(width)))
if total <= width:
return full # complete, from the start, at the left edge. No crop, no slide
end = total - self._trail_scroll # one cell after the last cell in the window
inner = width - (1 if self._trail_scroll else 0)
left = 1 if end > inner else 0
window = min(end, inner - left)
start = end - window
line = Text(style=full.style, no_wrap=True)
if left:
line.append_text(cut_mark(full, start, ELIDE_HEAD))
line.append_text(crop_cells(full, start, window))
if self._trail_scroll:
line.append_text(cut_mark(full, end - 1, ELIDE_TAIL))
return line
def _trail_max_scroll(self, width: int) -> int:
"""How far ← can slide the trail: the first whole step that shows its head.
A slide past that point only makes a line shorter that already shows all its
cells. Thus the slide stops there. The F-key lane says the same thing when it dims
a key that does nothing. The stop is a whole :data:`_HSCROLL_STEP`, not the exact
cell that makes the head visible. A slid line gives one cell to its right mark,
so its last window is one cell narrower than the lane. If the slide stops before a
whole step, the *left* mark stays drawn, and it shows a head that ← can no longer
reach. (Each other path row in a window in the app clamps the same
way.) The result is ``0`` for a trail that fits. Thus ← does nothing on a short
walk, and the handler does not have to know the width.
The result is cached on ``(width, trail)``: it costs a render, and the answer
changes only when the walk or the terminal changes.
"""
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]
total = PathLine(hops, separator=_TRAIL_SEP).text().cell_len
limit = 0
if total > width:
steps = -(-(total - (width - 1)) // _HSCROLL_STEP)
limit = steps * _HSCROLL_STEP
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 the colour of its identity.
Our node takes the :data:`~meshterm.ui.pathline.SELF_GLYPH` of the app instead of
its name. Each walk starts from our node, and the trail is the line that has the
least free width. Thus the star says in one cell what a full name says in many.
The cells that it makes free hold more steps that stay visible before the head
must be cropped.
"""
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:
"""The node in focus: glyph, name with its hash in parentheses, distance, and recency.
The first glyph shows the node type with its *shape* and with its *colour*: the
colour of the type (:meth:`_glyph_style`, the same as the canvas marker). Thus the
line does not write the type in words. The name shows as ``name (hash)``: the
addressed path hash in parentheses, with its digits lit in the hue of the node,
not a bare part of the key.
The ``N hops out`` distance is the *shortest* observed path from our node to this
node (a BFS over the full evidence graph, :meth:`_hops_out`): the ring of the node
in the mesh. On purpose, it is **not** the length of the breadcrumb trail above,
which is the route that you *walked* to get to the focus. If you walk out far and
come back, or step to a node that a shorter link also gets to, the walk is longer
than the ring. The two values answer different questions ("how near is this
node?" and "how did I get here?"). Thus a "hops out" that is shorter than the
trail is correct, not a wrong count.
"""
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 addressable path hash of the node as hex, or ``''`` for a placeholder id.
This is the first ``prefix_bytes`` bytes of a hex id (at least one, so that there
is always a hash to show). A stand-in that is not hex, such as ``"local"`` (our
node with no key), has no hash, and the result is the empty string. Thus the focus
line does not show the parentheses.
"""
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: str | None) -> list[str]:
"""Draw the focus neighbourhood: the focus at the far west, the strongest fan east.
The focus icon is at the far west, with its name to its right. The fan spreads east
across the available width (refer to :meth:`_place_neighbours`), and the name of
each neighbour is to the right of its marker. Each marker is a complete
**node-type mark**, with its glyph and its colour (:meth:`_marker_rgb`): the same
mark that the map and the route graph use for that node. The identity of the node
is in the *name* next to the marker, in the hue of the key. No marker changes its
colour, also not the marker of the selection. The highlighted row shows as a white
*label* and as the lit route.
While the user types a find query, the fan narrows to the neighbours that match.
The east is *the ways onward*, and the query asks about them. The focus stays
through the find: it is the walk until now, not a candidate. Thus the picture
keeps its orientation while the choices become fewer. The node that the walk came
*from* is not drawn (JP, 2026-08-09). It is behind you, and ⌫ takes you back to
it. When it was drawn, the one thing on the canvas that is not a way onward looked
like one.
Only as many neighbours as the area can hold get their own marker (refer to
:meth:`_fan_capacity`). The remaining weaker neighbours collapse into one ``…``
marker at the foot of the fan. When the list highlights a collapsed row, that
marker *stands in for* the highlighted node: its type mark, its name, and a grey
``(2/17)`` for its position among the collapsed nodes. Thus a selection is never
invisible, and the user never thinks that it is a separate node. Each edge starts
at the *right end of the focus name*, so that a line never crosses a label. The
edge to the selected link is the brightest, is not dimmed, and is drawn over the
others.
"""
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 must have a reason for it. It
# stands in for the node of the last slot *and* all the nodes that did not fit,
# never for one node only.
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 layout of the … slot is for the label that it has at rest. The name of a
# stand-in must not move it. The geometry must not depend on the selection. If it
# does, the full fan moves around while the highlight goes through the collapsed
# nodes. A long name that does not fit the slot is truncated (JP, 2026-08-09).
placed = self._place_neighbours(
width, canvas_h, shown, bool(hidden), more_label=f"+{len(hidden)} weaker"
)
# The edges first (the markers and labels overprint them). The SNR sets their
# colour, and the age of the evidence makes them fainter. Each edge starts at the
# anchor at the end of the focus name and ends on the marker of its neighbour.
# The edge of the selected link is the brightest and is on top. It does not get
# the age fade, so it shows as one lit line over the dimmer others. (The user can
# still read the age in the age column of the row.) The edge of the collapsed
# marker is grey. But when the selection is one of its nodes, it becomes the
# edge of that node.
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, with its name to the RIGHT: icon at the left,
# name at the right, in the direction in which the user reads the walk. The edges
# start at the end of the name. Thus they go east away from the label, not 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))
# The markers first, then the labels. Each marker has the colour of its node type.
# The selection shows as a white *label* and the lit route, never as a new colour
# on a mark that has a different meaning. The labels go on in order of importance
# (the selection, then the strongest links). Thus, where two labels overprint, the
# collision check removes the less important one.
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_MARK[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_MARK[1])
if stand_in is not None:
# The marker is now that node: its own mark, and its name in the white of
# the selection. A collapsed node is drawn *only while it is selected*, so
# here it is white, exactly as it would be with a marker of its own. The rank
# in the collapsed set is *west* of the mark (JP, 2026-08-09), not after the
# name. Thus the name ends where each other name on the fan ends, and the
# count reads as a note on the marker, not as a part of the name of the node.
# The count keeps the stand-in correct: a name alone tells the user that 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)``: the position of ``node`` among the collapsed links, and their count."""
return f"({hidden.index(node) + 1}/{len(hidden)})"
def _fan_nodes(self) -> list[str]:
"""The ways *onward* from the focus: each neighbour but the one the walk came from.
This is the only definition of the content of the east of this screen. The canvas
and the link list share it, so that the picture and the rows always agree about
the choices, also under a find, which narrows the two. The node that the walk came
from is excluded (JP, 2026-08-09). You were there before, and ⌫ takes you back to
it. When the list showed it as a link, it asked for a walk that only undoes the
last walk, and it took the top row of the list. Without it, the strongest link is
always the row where the highlight starts.
"""
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 holds, with the row of the ``…`` marker.
The fan is a **grid of rows, not the spread of an arc** (JP, 2026-08-09): one
marker on each row, with a blank row between two markers, and a blank row above
and below the block. That is ``2n + 1`` rows for ``n`` markers. With equal
spacing, a fan of five reads as five, not as a group at the rim. An equal spacing
of the *angle* gave that group, because its rows came from a sine, so they were
close together at the top and the bottom.
The outer blank rows are the part that can go. If a canvas with them is too short
for :data:`_FAN_MIN_SLOTS` neighbours, it removes them and fits ``n`` in
``2n - 1`` rows instead. Empty space is worth a row until it costs a *node*: the
screen exists to show the neighbourhood.
"""
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 for the ``slots`` markers of the fan, from top to bottom.
The rows are centred as one block. Thus the outer blank row comes back when the
canvas is taller than the block, and it goes equally from the two ends when the
canvas is not (refer to :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 dot position of the focus marker: near the west edge, name and fan to its east.
The icon is near the left edge. Thus its name (drawn to the right) and the fan of
neighbours have the full width to spread across: the graph has space, and it is
not pushed into the middle.
Vertically, it is level with the middle of the **fan**, not of the canvas. The
block of ``slots`` markers is centred, and the focus is centred on *the block*.
Thus the edges from the focus spread equally up and down, for an odd or even
number of markers. When there is no fan (``slots`` 0: a leaf node, or a find that
matches nothing here), the focus is on the middle line of the canvas.
"""
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 attach point of the edges on the focus, and the focus name on the canvas.
The focus icon is at the far west (:meth:`_focus_pos`), with its name to the
*right*. Each edge starts at the **right end of that name**, so that the lines
never cross the label. The result is the attach point in dot coordinates and the
name as it is drawn (clipped to the space 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]]:
"""The dot positions of the drawn fan: a grid of rows east of the focus name.
Each node that shows takes its own row, east of the anchor at the end of the focus
name. The strongest link is at the top and the weakest is at the bottom. This is the
same order as the list below, so the picture and the rows agree. The collapsed
``…`` marker (key :data:`_MORE`) takes the last slot of the fan. The rows come from
:meth:`_fan_rows`: equal spacing, one blank row between two markers, and the block
centred.
The distance to the east follows an arc, and the name that each marker must show
limits it **for each marker** (JP, 2026-08-09). The fan reaches to a tip sized for a
usual name (:data:`_FAN_BASE_LABEL_W`). Then each marker whose name needs more
space than that moves west by exactly the difference. Thus one long name makes the
reach shorter only on *its* row, and the rest of the fan stays where it was. The
first version sized the full fan for the longest name, so one repeater name of 29
cells pulled each marker west. The version before that did no sizing, so a fixed
margin of 20 cells ellipsized each longer name, also on a canvas with free space.
A marker never comes further west than :data:`_MIN_FAN_REACH` of the width,
whatever its name needs. Past that point, the markers pile onto the focus and the
graph does not look like a fan. Thus the label placer truncates a name that is too
long for the remaining space.
Only the *row* of a leaf sets its angle. Its east reach curves by
:data:`_FAN_X_FLATTEN` of that angle. Thus the fan has a gentle curve and is not a
flat column, and the leaves at the rim still reach far east and do not curve back
toward the focus on a true circle. A small fan uses a proportionally smaller part
of the arc, so two neighbours are near due east, not at opposite rims. The anchor
is at the end of the name (not at the icon). Thus the fan has the full width east
of the focus label.
Args:
width: The canvas width in cells.
canvas_h: The canvas height in cells.
shown: The neighbours drawn with markers of their own, the strongest first.
more: Whether a collapsed ``…`` marker takes the last slot.
more_label: The label of that marker at rest. Thus the layout of its slot is
for the label that it *usually* has, not for a selection that soon moves.
"""
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 cell of the marker, a blank, then the label: these must be east of a marker.
# They are in cells, multiplied by two for 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, strict=True)):
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, # the middle of the cell, so that an edge meets the glyph
)
return placed
@staticmethod
def _clip(label: str, room: int, cap: int = _LABEL_W) -> str:
"""``label`` fit to ``room`` cells.
The label is complete if it fits, or else ellipsized (only ``…`` at one cell,
nothing at zero). The limit and the space to the edge both go through here. Thus a
name is never made shorter than is necessary.
"""
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. On a collision, try one row below, then one row above.
The label goes to the right of the marker where it fits, or else to the left.
The label is clamped to the side that has more space: the free cells to the
right of the marker, or to its left. Thus the selection keeps as much of its name
as the canvas permits before it ellipsizes.
"""
cx = x >> 1
room = max(canvas.cell_w - (cx + 2), cx - 1) # the larger space, right or 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 label to the *right* of its marker, on another row if necessary.
Each node except the selected one (which keeps the two-sided :meth:`_place_label`)
has its name to the right of its icon: the direction in which the user reads the
walk. The label tries the row of the marker first. Then it tries a row below and
above, to go past a neighbour that is too near. If all the checked rows are
blocked, the label is stamped to the right in all cases, so that a node is never
only a bare glyph. The name is clamped to the free cells between the marker and
the canvas edge. :meth:`_place_neighbours` already sized the fan to keep these
cells free, so on a canvas that is wide enough the clamp never cuts a name.
"""
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 note to the *left* of a marker, on the row of the marker.
This note is the only thing drawn on that side (the rank of the collapsed marker,
refer to :meth:`_canvas_lines`). It stays on the row that it is about and does not
move. If the counter moves one row, it looks like a part of the neighbour above.
West of a fan marker there is only the incoming edge of the marker and little
else. Thus the note is placed in all cases, and it overprints the braille
under it: the same fallback as at the end of :meth:`_label_right`. The run includes
a blank after the note, so that the gap before the mark is a real gap. If that
cell is not written, the braille of the incoming edge goes through it and joins
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 legend of the glyphs and the edges under the canvas.
The legend explains the two halves of a mark, because the canvas now draws complete
marks. It shows the glyph of each role in the same colour that it has on the
canvas, so the line is a sample, not a chart of shapes. The pairs come from the same
source as in :meth:`_glyph`. Thus the legend cannot become different from the
picture. This is also true on the console, where the ``type.*`` names let each mark
select its slot on purpose, and not get the slot to which a downsample of the hex
moves it. The words stay muted: they are chrome, and the marks are the content.
"""
legend = Text()
for (glyph, colour), word in (
(SELF_MARK, " you "),
(NODE_GLYPHS[NODE_TYPE_REPEATER], " repeater "),
(DEFAULT_GLYPH, " node "),
(UNKNOWN_MARK, " 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, in a list window of ``win`` lines under a pinned heading.
Only the rows scroll. The heading (and all that is above it) does not move. When
the list is longer than the list window, faint ``↑/↓ n more`` markers take the
edge rows of the list window, and the highlight stays in the remaining rows. The
row count of the list window becomes the PgUp/PgDn step.
"""
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
# The rows set *which* links this list has, and in which order (:meth:`_rows`).
# The code finds a link in the link table only *through* the rows. There was a
# bug when the list rendered directly from the table: the table still had the
# node that the walk came from. Thus each index after it named one row and drew
# another, and the highlight was one link away from the link that the canvas
# lit. The onward counts still come from the full table: that cache is for each
# focus, not for each paint, and a narrowed list must not fill 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 ``(name, key)`` lane widths of the list: the name lane first, then the key.
The name lane is as wide as the widest name in the rows (a list of short names
stays narrow). The key lane takes the remaining width, with a limit of the full id
of 12 hex digits. When the two together are wider than the row, the *key* becomes
narrower first. It shrinks on a byte boundary down to a minimum that still shows
its full lit hash and a ``…``. Only when the key is at that minimum does a long
name start to lose letters. Thus the name is the last thing that is truncated, and
the hash is never truncated (the user reads names, and addresses by hash).
"""
hash_w = max(2, min(_LIST_HASH_W, self._prefix_bytes * 2))
# The narrowest key lane that still shows the full hash: the hash and a "…". The
# width is odd, so :func:`highlighted_hash` keeps an even number (hash_w) of digits
# with no unused cell. When the hash already fills the id, there is nothing to
# remove, so the minimum 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, without the link back here.
The result is cached for each focus (the counts depend only on the frozen graph and
on the node whose neighbours the list shows). Before the cache, this was quadratic
at each paint: each neighbour went again through a new copy of the full 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 from the neighbour "continues onward" if its far end is not here. One
# endpoint is the neighbour, so only the far endpoint can be the focus.
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 and 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 its distance from our node."""
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 name colour in the list: the node hue (from its key), our node 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. The colour shows a name
return "node.unknown"
return name_style(label, node)
def _known(self, node: str) -> bool:
"""Whether the node is identified (named, or our node), so that its hash can have a hue.
Each hash lane here gives this gate to
:func:`~meshterm.ui.widgets.highlighted_hash`. The full hash of an unknown node is
grey, the same as 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 of a node: its *type* colour, as in all places (JP, 2026-08-09).
The walk once gave each marker the colour of its identity (the hue of the node,
from its key), and only the glyph shape showed the type. Thus this canvas was
different from each other place that shows a node with its type (the map, the
route graph, a contact row). There, the mark *is* the type, in the ``type.*`` styles
of the theme, which each platform resolves for itself. The identity has its own lane
here, and always had it: the **name** next to the marker, in the hue of the key
(:meth:`_label_rgb`). The shape and the colour now agree, and the legend under the
canvas can explain 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 hue, our node white, a bare hash grey.
This is the RGB form of :meth:`_list_name_style`: the hue of the key for a node
with a name, white for our node, and the grey of an unknown node for a node known
only by a bare hash. A key that stands in for a name never gets a colour of its
own. Thus the hash label stays grey, and the marker shows only the type mark
(refer to :meth:`_marker_rgb`).
"""
style = self._list_name_style(node)
if style == "you":
return (255, 255, 255)
if "#" not in style: # a theme name (node.unknown). The platform selects the shade
return mark_rgb(style)
return parse_hex(style.rsplit("#", 1)[-1])
def _glyph_style(self, node: str) -> str:
"""The Rich style of the type glyph of a node in body text: the colour of its type.
This is the text form of :meth:`_marker_rgb`: the same ``type.*`` entry (or the
white of our star). Thus the first glyph of the focus line, the glyphs of the list
rows, and the canvas markers are one mark, drawn in three ways.
"""
return self._glyph(node)[1]
def _empty_state(self, width: int) -> list[str]:
"""A helpful 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]:
"""The mark of a node: the type glyph and the colour of that type in all the app.
These are the shared marks (:data:`~meshterm.ui.widgets.NODE_GLYPHS`) and the
shared ``type.*`` colours for them. Thus a node shows here with exactly the glyph
and colour that the map, the route graph, and the contact list use for it: a white
``▲`` for a repeater, ``■`` for a room, ``◉`` for a sensor, ``●`` for a plain node,
``★`` for our node, and the grey ``○`` for a node that we heard of but never
identified. Thus the legend below the canvas is a real explanation, not a
coincidence.
Returns:
``(glyph, colour)``: the colour in the encoding that
:func:`~meshterm.ui.theme.mark_rgb` takes (a literal hex, or a theme name where
the platform must select its own slot).
"""
if node == self._topo.self_id:
return SELF_MARK
contact = self._contacts.get(node)
if contact is None:
return UNKNOWN_MARK
return NODE_GLYPHS.get(contact.node_type, DEFAULT_GLYPH)
def _label(self, node: str) -> str:
"""The display name: our own name for our node, a contact name, or a 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 the user closes it.
The contacts and our identity come from the device when the device can be reached.
This is best-effort: the stored evidence draws correctly without them, but with
hashes instead of names. The graph comes fully from the repository, as a snapshot
taken one time when the screen opens. Nothing is ever transmitted.
Args:
ctx: The shared application context (it must run the interactive TUI).
Raises:
RuntimeError: If the call is not from the interactive menu (no full-screen
session).
"""
from ..services.topology import build_topology
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - the caller is only in the menu
raise RuntimeError("the mesh walk is only available in the menu")
session = ctx.ui.session
contacts: list[Contact] = []
self_label = "you"
self_hash: str | None = 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 optional. The graph renders without them
contacts = []
prefix_bytes = await ctx.devstate.routing_prefix_bytes()
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 full paint from a clean screen as on the map. Braille fallback glyphs
# may have smeared cells that the differential paint of prompt_toolkit never
# writes again.
session.request_full_repaint()