`ruff format .` across the repo: 153 files, no behaviour change. This is the second gate CONTRIBUTING.md promises, and until now it reported 154 files needing reformat — so a first contributor ran the command the guide told them to run and could not tell their own diff from the background. Landed on its own, with nothing else in it, because a whole-repo reformat is only safe to skip past in blame if it is provably nothing but layout. It was checked that way rather than assumed: every changed file was formatted in memory and compared by full `ast.dump()`, and 152 of 153 come out structurally identical (the one exception was a docstring opening on a quote character, fixed in the commit before this one). The string and comment token streams were diffed separately, so a quote flip or an edited comment could not hide inside a re-layout — five quote-style normalisations and sixteen implicit-concatenation joins, all value-identical, and one commented-out line gaining the space after its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite is 1888 green, gallery gate and `test_theme16` included. What actually changed, in order of how much of the diff it is: wrapped calls and signatures rejoin onto one line where they fit inside the hundred columns; the magic trailing comma expands what it is there to expand; complex slices gain their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are laid out to be read were fenced in the previous commit and are untouched here. The aligned trailing-comment column is gone from about ninety lanes that were not fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced where it sits. Every comment stays on the entry it describes, so nothing there reads wrong; it just no longer lines up. Add this commit to `.git-blame-ignore-revs`, which follows immediately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2060 lines
100 KiB
Python
2060 lines
100 KiB
Python
"""The Node detail screen: one node's whole story on a single page, and the ways into it.
|
||
|
||
Reached by pressing Enter on any contact in the Contacts list (see
|
||
:mod:`~meshterm.ui.contacts_screen`). Where the list is one aligned row per node, this is
|
||
the node itself, in full:
|
||
|
||
* **who it is** — a one-line identity header (its type glyph, its name in the node's own
|
||
hue, its type label), the only chrome pinned above the stage — so the stage keeps nearly
|
||
the whole screen;
|
||
* **a tabbed stage** below it — one full-height view at a time, switched with
|
||
``Tab``/``Shift+Tab`` across a boxed tab strip (see
|
||
:func:`~meshterm.ui.widgets.tab_strip`). Rather than stack the vitals, the location
|
||
preview, and the route graph down one long scroll, each view earns the whole stage:
|
||
|
||
* **Info** — the node's vitals as labelled rows (its key with the routing hash lit, when
|
||
it was first and last heard, how many packets we've overheard, its reception SNR and
|
||
last RSSI, and where it sits). The key is a single lane, never wrapped: a full public
|
||
key outruns the row on any terminal we target, so ``←→`` scroll it a whole four bytes
|
||
at a time under faint ``…`` edge marks, exactly as a long pathline scrolls on the
|
||
Routes tab. Then — when the node has advertised a location — a
|
||
static basemap preview (see :class:`~meshterm.ui.minimap.MiniMap`) centred on the node,
|
||
grown to whatever rows the viewport spares. Its actions: ``Open full map`` (the full
|
||
map opens centred here with its find filter seeded to this node, so it lights among
|
||
the rest), ``Time machine``, ``Share contact`` — a popup contact card (QR code +
|
||
``meshcore://`` link, see :func:`~meshterm.ui.config_editor.show_contact_card`),
|
||
offered whenever the node's full key is known — and, last, ``Remove contact``: the
|
||
single-contact counterpart to the Contacts list's bulk purge (see
|
||
:mod:`~meshterm.ui.contacts_screen`), dropping *this* node from the device's contact
|
||
table behind a red confirm. It is the one thing on the page that changes anything, so
|
||
it sits at the foot of the actions, and committing it closes the page — the contact it
|
||
details no longer exists to detail.
|
||
* **Routes** — the routes we've actually heard the node arrive over, drawn on the shared
|
||
route graph (:mod:`~meshterm.ui.pathgraph`) node→us (the inbound direction the packets
|
||
travelled, contact on the left, us on the right), each relay tagged by its first hash
|
||
byte alone — two cells, so the fan reads however many lanes it carries. Beneath the
|
||
graph sits the *route list*, where the names live: one selectable row per distinct route
|
||
(the firmware's learned route and the observed alternatives, the strongest marked
|
||
``★ best``), each spelled out through THE path widget as a single **named** line — a hop
|
||
nobody can name standing in its hash — with its bottleneck SNR / sample count / tag
|
||
hanging on the line below. The two bands cross-reference by node hue rather than by
|
||
spelling the same hex twice; it is the Message paths dialog's split exactly.
|
||
``↑↓`` moves the selection; the picked route lights white in the graph
|
||
while the rest go grey and every node not on it fades its label to grey, so the graph
|
||
reads as *this* route through the fan. A pathline too wide for the lane never wraps —
|
||
the highlighted row instead scrolls horizontally (``←→``) under faint ``…`` edge marks
|
||
to read it to the end; an unselected long row just ellipsizes. Only *good* routes are
|
||
drawn — stale evidence and
|
||
far-weaker outliers are dropped, so the list is the routes worth trusting rather than
|
||
every chain ever heard. Enter on a route arms a trace on it (nothing transmits here — it
|
||
opens the trace screen loaded with that path); when there is no route evidence to list, a
|
||
``🎯 Trace — auto route …`` action stands in.
|
||
|
||
* **the ways in** — each tab's action rows (``↑↓`` moves the cursor through them, Enter
|
||
commits) and the shared ``Back`` closing every tab; Esc backs to the list.
|
||
|
||
The page never scrolls as one long strip. The identity header, the tab strip, and the
|
||
stage are pinned; the stage sizes itself to the terminal (the route graph compresses its
|
||
lanes before it would overflow, the location preview grows into what the Info tab spares)
|
||
and a faint rule closes it, so the drawn view and the rows below read as separate bands.
|
||
The route list scrolls *inside* the leftover rows with faint ``↑ n more`` / ``↓ n more``
|
||
edge markers — the app-wide windowed-list pattern (see
|
||
:class:`~meshterm.ui.tui.screen.ListWindow`, fitting whole blocks) — so the graph, the
|
||
selection driving it, and the action rows share one screen however many routes a busy
|
||
node has. ``PgUp/PgDn`` page the cursor through the window.
|
||
|
||
The screen is a pure read-and-route view: it renders already-resolved display data and
|
||
resolves an action token (the Trace token carrying the selected route's spec via
|
||
:meth:`NodeDetailScreen.selected_spec`); :func:`open_node_detail` owns the data-gathering
|
||
and runs the sub-flows each action opens, then re-shows the page — the same loop the Time
|
||
Machine and Contacts list use. Nothing here transmits.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import math
|
||
import re
|
||
import time
|
||
from dataclasses import dataclass, field, replace
|
||
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.connection import ContactNotOnDeviceError
|
||
from ..core.geo import EARTH_RADIUS_KM, usable_fix
|
||
from ..core.models import NODE_TYPE_LABELS, Contact, utcnow
|
||
from ..platforms import Platform, on_platform
|
||
from . import menus
|
||
from .mapcanvas import RGB
|
||
from .minimap import MiniMap
|
||
from .pathgraph import (
|
||
DST_NODE,
|
||
SRC_NODE,
|
||
GlyphOf,
|
||
LabelOf,
|
||
LabelRgbOf,
|
||
PathLayer,
|
||
bidir_clusters,
|
||
render_path_graph,
|
||
)
|
||
from .pathline import (
|
||
ELIDE_HEAD,
|
||
ELIDE_TAIL,
|
||
SELF_GLYPH,
|
||
PathHop,
|
||
PathLine,
|
||
cut_mark,
|
||
cut_to,
|
||
hops_atom,
|
||
with_action_mark,
|
||
)
|
||
from .theme import mark_rgb, name_style, snr_style
|
||
from .tui.render import crop_cells, render_hanging, render_lines, render_to_ansi
|
||
from .tui.screen import CANCEL, ListWindow, Screen
|
||
from .widgets import (
|
||
_DEFAULT_GLYPH,
|
||
_NODE_GLYPHS,
|
||
_age_seconds,
|
||
_recency_style,
|
||
format_ago,
|
||
highlighted_hash,
|
||
node_type_legend,
|
||
tab_strip,
|
||
)
|
||
|
||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||
from ..context import AppContext
|
||
|
||
#: Our own node's marker glyph and hue, matching the map/mesh-walk star.
|
||
_SELF_GLYPH = ("★", "#facc15")
|
||
|
||
#: Blank rows of air around the tab strip — one above and one under on the desktop; none
|
||
#: on the PicoCalc (JP, 2026-08-08), where rows are the scarce resource and both lines go
|
||
#: to the stage instead (the route graph's ceiling, the location preview's growth room).
|
||
#: Rides the same frugality signal as the borderless frame, and is bound at
|
||
#: platform-switch time like every platform-derived constant — never branched per paint.
|
||
_TAB_AIR = 1
|
||
|
||
|
||
@on_platform
|
||
def _bind_tab_air(platform: Platform) -> None:
|
||
"""Bind the strip's air to the platform (runs now and on every switch)."""
|
||
global _TAB_AIR
|
||
_TAB_AIR = 1 if platform.frame_border else 0
|
||
|
||
|
||
#: Whether the route fan is captioned ("node → you, as heard · white = selected route")
|
||
#: and allowed to keep the empty canvas row its top air leaves. Both go on the PicoCalc
|
||
#: (JP, 2026-08-09) and both rows go to the graph instead: the caption explains a picture
|
||
#: the white line and the ``you`` star already make, and the renderer's end padding
|
||
#: (:data:`~meshterm.ui.pathgraph._GRAPH_END_DOTS`, two cells) reserves a whole cell of air
|
||
#: above the topmost lane's label that nothing ever draws into. Rides the same frugality
|
||
#: signal as :data:`_TAB_AIR`, bound at platform-switch time like every platform constant.
|
||
_GRAPH_CAPTION = True
|
||
|
||
|
||
@on_platform
|
||
def _bind_graph_caption(platform: Platform) -> None:
|
||
"""Bind the fan's caption/air to the platform (runs now and on every switch)."""
|
||
global _GRAPH_CAPTION
|
||
_GRAPH_CAPTION = platform.frame_border
|
||
|
||
|
||
#: An SGR escape, so a rendered canvas row can be tested for holding nothing but air.
|
||
_SGR = re.compile(r"\x1b\[[0-9;]*m")
|
||
|
||
#: The inline location preview's row bounds: it grows into whatever the Info tab's
|
||
#: viewport spares (the vitals and action rows are short), floored so a cramped terminal
|
||
#: still shows a recognisable neighbourhood and capped so a tall one doesn't become all map.
|
||
_MAP_MIN_ROWS = 5
|
||
_MAP_MAX_ROWS = 13
|
||
|
||
#: Route-graph tuning for the Routes tab. It draws the contact on the left and us on the
|
||
#: right (node → us, so the graph reads left to right as the inbound direction its packets
|
||
#: travelled to reach us). A well-connected node offers several candidate paths at once, and
|
||
#: cramming them into a short box compresses the lanes together — so this page keeps the shared
|
||
#: lane pitch and lets the graph grow with its lanes, up to this ceiling. The real budget is
|
||
#: struck per render against the terminal: the stage takes what the viewport leaves after the
|
||
#: pinned chrome, the action rows, and the route list's guaranteed window — so the graph
|
||
#: compresses its pitch before it would ever push the list or the actions off the screen, and
|
||
#: this ceiling is only reached on a terminal tall enough to afford it.
|
||
_PATH_MAX_ROWS = 22
|
||
|
||
#: The fewest canvas rows the route graph is ever granted (the shared renderer's own floor);
|
||
#: on a terminal too short for even this, the frame's cursor-follow keeps the active row in
|
||
#: view rather than the stage shrinking into noise.
|
||
_GRAPH_MIN_ROWS = 5
|
||
|
||
#: Route-list lines the stage must leave room for before taking the rest of the viewport —
|
||
#: enough for a couple of routes (or one wrapped one) to show beside the graph they light.
|
||
_LIST_MIN_LINES = 4
|
||
|
||
#: A drawn route is dropped as *stale* when its freshest-limiting link — the stalest hop it
|
||
#: rides through — has not been heard in this many days. A route is only as current as its
|
||
#: weakest-heard link: if any hop along it has gone quiet, the whole chain may no longer
|
||
#: carry. Matches the spirit of the topology's one-week evidence half-life (four half-lives
|
||
#: leaves a link weighted ~1/16), the point past which a path is more memory than fact.
|
||
_PATH_STALE_DAYS = 28.0
|
||
|
||
#: A drawn alternative route is dropped as an *outlier* when its evidence score falls below
|
||
#: this fraction of the strongest observed alternative's. Keeps the graph to the handful of
|
||
#: routes actually worth trusting rather than every far-weaker chain the evidence can string
|
||
#: together. The best-evidence (white) route is always drawn regardless — it is the answer to
|
||
#: "how do we reach it", not one of the alternatives being weighed.
|
||
_PATH_OUTLIER_RATIO = 0.25
|
||
|
||
#: Cells the labelled info rows reserve for their label lane, so the value blocks line up
|
||
#: and a wrapped value hangs under itself rather than under the label (the app-wide
|
||
#: hanging-indent rule). Sized to the widest label the block uses ("packets").
|
||
_LABEL_LANE = 9
|
||
|
||
#: The selected route's edge colour (white, the spine) over the alternatives' grey, and the
|
||
#: grey a node's label fades to when it sits on no part of the selected route. One grey for
|
||
#: both, so an off-route relay's line and its name read as the same "not this route" dim.
|
||
_WHITE = (255, 255, 255)
|
||
_GREY = (120, 120, 120)
|
||
|
||
#: The synthetic node-id prefix a contracted bidirectional cluster draws under. A ``\x00``
|
||
#: lead keeps it non-hex (so the graph never tries to coalesce it against a hash) and clear of
|
||
#: any real hop, mirroring the graph's own endpoint sentinels.
|
||
_CLUSTER_NODE = "\x00clu"
|
||
|
||
#: Columns a route row's pathline and its hanging context line indent by — the ``"❯ "``/
|
||
#: ``" "`` pointer's own width, so the context lines up under the path it belongs to.
|
||
_ROUTE_INDENT = 2
|
||
|
||
#: Cells one ←/→ press horizontally scrolls the highlighted route's pathline, or the Info
|
||
#: tab's key lane (see :meth:`NodeDetailScreen.handle`) — the same step the app-wide select
|
||
#: list's ``hscroll`` uses. On a key it happens to be exactly four bytes, so every window the
|
||
#: lane can settle on starts on a byte boundary, the way a hash reads.
|
||
_HSCROLL_STEP = 8
|
||
|
||
#: The info row whose value is a key: the one lane that scrolls (``←→``) rather than wraps.
|
||
#: A public key is 64 hex digits — wider than the value lane on either platform, and a run
|
||
#: with no word to break on — so wrapping it just buys a second line that says nothing,
|
||
#: while every other vital here is prose short enough to hang under itself.
|
||
_KEY_LABEL = "key"
|
||
|
||
#: The faint mark drawn at whichever edge of the key lane the key continues past.
|
||
_MORE_MARK = "…"
|
||
|
||
#: Cursor moves that abandon a route's in-progress horizontal scroll — each row scrolls on
|
||
#: its own, so leaving it resets the shift rather than carrying it to whatever row is next.
|
||
_HSHIFT_RESET_ACTIONS = frozenset(
|
||
{
|
||
"up",
|
||
"down",
|
||
"pageup",
|
||
"pagedown",
|
||
"home",
|
||
"ctrl_home",
|
||
"end",
|
||
"ctrl_end",
|
||
}
|
||
)
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class _Action:
|
||
"""One action row at the foot of a tab.
|
||
|
||
Attributes:
|
||
key: The token the screen resolves with when this row is committed.
|
||
glyph: The leading icon.
|
||
glyph_style: The icon's style.
|
||
label: The row's text (a current value inlined, muted where it's context).
|
||
"""
|
||
|
||
key: str
|
||
glyph: str
|
||
glyph_style: str
|
||
label: str
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class _Route:
|
||
"""One selectable route on the Routes tab: how it draws, how it traces, how it reads.
|
||
|
||
Attributes:
|
||
draw: The relay hops in the graph's inbound draw order (contact → us), so a
|
||
:class:`~meshterm.ui.pathgraph.PathLayer` built from them lands the contact on
|
||
the left endpoint and our star on the right. Empty = a straight zero-hop shot.
|
||
spec: The forced-path spec a trace arms on when this route is selected (the symmetric
|
||
round trip through these hops); ``""`` lets the trace screen auto-resolve.
|
||
path: The pre-rendered pathline — the whole route named through THE path widget,
|
||
contact → relays → us — with the trailing opens-further-prompts mark applied at
|
||
render time (:data:`_OPENS_MARKER`), not baked in here. One line, never wrapped:
|
||
the highlighted row instead horizontally scrolls to read a long hash chain past
|
||
the lane's width, and an unselected row simply ellipsizes.
|
||
context: The line shown hanging under ``path`` — the bottleneck SNR, sample count,
|
||
and a ``★ best`` / ``device route`` tag — empty when a route earns none of them.
|
||
"""
|
||
|
||
draw: tuple[str, ...]
|
||
spec: str
|
||
path: Text
|
||
context: Text
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class _Cluster:
|
||
"""A contracted bidirectional cluster's stand-in marker on the graph.
|
||
|
||
Three or more repeaters that relay each other in every order are a knot the left-to-right
|
||
flow can't seat (see :func:`~meshterm.ui.pathgraph.bidir_clusters`); they draw as one
|
||
super-node instead, and this is how it presents. The route rows below the graph still name
|
||
every member in order, so the detail the marker folds away is one glance down.
|
||
|
||
Attributes:
|
||
glyph: The marker glyph — the members' shared node-type mark when they agree
|
||
(``▲`` repeater, ``■`` room, …), else the plain node dot.
|
||
color: The marker glyph's ``#rrggbb`` colour (the node-type hue, or the plain dot's).
|
||
label: The count-and-type label, e.g. ``3 repeaters`` (``n nodes`` when mixed).
|
||
rgb: The label colour — the same type hue as the marker, so the super-node reads as a
|
||
typed group rather than a named node.
|
||
"""
|
||
|
||
glyph: str
|
||
color: str
|
||
label: str
|
||
rgb: tuple[int, int, int]
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class _RoutesView:
|
||
"""The Routes tab's selectable routes and the shared per-node draw callbacks, or a note.
|
||
|
||
The routes are drawn as one fan (best-evidence spine plus alternatives); which one lights
|
||
white — and which nodes keep their name hue rather than fading to grey — follows the
|
||
screen's live selection, so the layers and label colours are composed per render rather
|
||
than baked in here.
|
||
|
||
Attributes:
|
||
routes: The selectable routes (strongest first), or empty when there is no route
|
||
evidence at all — then ``note`` carries the muted stand-in.
|
||
glyph_of: Per-node marker callback for the graph.
|
||
label_of: Per-node label callback for the graph.
|
||
label_rgb_of: Per-node label-colour callback (the un-muted base; the screen fades the
|
||
off-route nodes over it).
|
||
legend: Whether to draw the node-type key beneath the graph (a typed relay showed).
|
||
note: The muted line shown instead of a graph when there is no evidence.
|
||
"""
|
||
|
||
routes: list[_Route] = field(default_factory=list)
|
||
glyph_of: GlyphOf | None = None
|
||
label_of: LabelOf | None = None
|
||
label_rgb_of: LabelRgbOf | None = None
|
||
legend: bool = False
|
||
note: str = ""
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class _Tab:
|
||
"""One tab in the stage's strip.
|
||
|
||
Attributes:
|
||
name: The strip label (``Info`` / ``Routes``).
|
||
kind: Which stage it draws (``info`` / ``routes``).
|
||
"""
|
||
|
||
name: str
|
||
kind: str
|
||
|
||
|
||
class NodeDetailScreen(Screen):
|
||
"""A full-screen page for one node: identity, a tabbed stage, and the ways in.
|
||
|
||
A read-and-route view. It renders already-resolved display data (see
|
||
:func:`open_node_detail`, which assembles it) and, on Enter, resolves the highlighted
|
||
action's token for the opener to act on (a Trace token is paired with
|
||
:meth:`selected_spec`); Esc resolves :data:`CANCEL` to leave.
|
||
|
||
Two axes of navigation, matching the app's spatial feel: ``Tab``/``Shift+Tab`` switches
|
||
which view fills the stage, and ``↑↓`` moves the cursor *within* the active tab — through
|
||
its route list (on the Routes tab, the selection drives the graph highlight and Enter arms
|
||
a trace on the picked route) and action rows. The page
|
||
itself never scrolls: the identity header, tab strip, and stage are pinned, the stage
|
||
is sized to the viewport, and the route list windows itself into the leftover rows —
|
||
``PgUp/PgDn`` page the cursor through it, ``Home/End`` jump it to the ends.
|
||
|
||
``←→`` read whatever the active tab holds that is wider than its lane, one lane per tab:
|
||
the highlighted route's pathline on Routes, the key on Info. Both are gated on actually
|
||
overflowing, so the keys stay inert — and unadvertised — where they would do nothing.
|
||
"""
|
||
|
||
floating = False
|
||
|
||
@property
|
||
def fkey_lane(self):
|
||
"""The shared pager over the route list, plus the tab switch on F3.
|
||
|
||
The strip shows *that* there are two views; nothing on screen says the key that
|
||
moves between them, and on a platform with no hint line the chip is the only place
|
||
to learn it. It names the tab it would take you *to*, never the one you are on —
|
||
the strip already marks that, and a chip repeating it would say nothing about what
|
||
pressing it does. (A per-tab chip on F1/F2 was tried and reverted — JP, 2026-08-09:
|
||
the toggle was better.) Unlike the Time Machine's window chip, this one carries no
|
||
``▸`` lead-in: ``Routes`` is exactly six cells on its own, and the strip beside it
|
||
makes the direction plain anyway. A page with one tab has nothing to switch, so
|
||
the slot stays empty.
|
||
|
||
The pager gates on the route list actually being windowed: on the Info tab, and on
|
||
a node whose routes all fit, the keys move nothing.
|
||
"""
|
||
from .tui.fkeys import FPair, default_lane
|
||
|
||
lane = list(default_lane(nav=self._list_hidden))
|
||
if len(self._tabs) >= 2:
|
||
nxt = self._tabs[(self._tab_index + 1) % len(self._tabs)]
|
||
lane[2] = FPair(nxt.name, "tab")
|
||
return lane
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
title: str,
|
||
header: Text,
|
||
info_rows: list[tuple[str, Text]],
|
||
tabs: list[_Tab],
|
||
minimap: MiniMap | None = None,
|
||
map_caption: Text | None = None,
|
||
routes: _RoutesView | None = None,
|
||
info_actions: list[_Action] | None = None,
|
||
trace_action: _Action | None = None,
|
||
) -> None:
|
||
"""Build the page over resolved display data.
|
||
|
||
Args:
|
||
title: The screen heading (``Node — <name>``).
|
||
header: The identity line: type glyph, the coloured name, its type label.
|
||
info_rows: ``(label, value)`` pairs for the Info tab's vitals block; each
|
||
renders as a muted label lane with the value hanging under itself when it
|
||
wraps.
|
||
tabs: The stage tabs to offer, in strip order (empty = no stage, just actions).
|
||
minimap: The Info tab's inline location preview, or ``None`` (no advertised
|
||
fix).
|
||
map_caption: A faint line under the preview (its centre/scale), when a map shows.
|
||
routes: The Routes tab's route list + graph callbacks (or a muted note), or
|
||
``None`` when there is no Routes tab (we never overhear our own node).
|
||
info_actions: The Info tab's action rows (Open full map when there is a fix,
|
||
Time machine when the recorder holds history, Share contact when the full
|
||
key is known).
|
||
trace_action: The Routes tab's ``Trace — auto route …`` action, shown only when
|
||
there are no routes to list (with routes listed, Enter on a route row is the
|
||
trace entry point), or ``None``.
|
||
"""
|
||
super().__init__()
|
||
self.title = title
|
||
self._header = header
|
||
self._info_rows = info_rows
|
||
self._tabs = tabs
|
||
self._minimap = minimap
|
||
self._map_caption = map_caption
|
||
self._routes = routes
|
||
self._info_actions = info_actions or []
|
||
self._trace_action = trace_action
|
||
#: The action rows' icon column, in cells — measured once over every mark this page
|
||
#: can draw, so a one-cell ``🗑`` pads out to its two-cell siblings and every label
|
||
#: starts in the same column. Zero on a platform that draws no icon lane at all.
|
||
self._icon_lane = menus.icon_lane(
|
||
action.glyph
|
||
for action in (*self._info_actions, *([trace_action] if trace_action else []))
|
||
)
|
||
self._tab_index = 0
|
||
self._row_index = 0
|
||
#: The highlighted route on the Routes tab (drives the graph); tracks the cursor as
|
||
#: it moves onto a route row and holds while it rests on an action row, so the graph
|
||
#: keeps showing the last pick.
|
||
self._route_sel = 0
|
||
#: The composed route fan for one (width, rows, selection) — see _routes_stage.
|
||
self._stage_memo: tuple[tuple, list[str]] | None = None
|
||
self._cursor: int | None = None
|
||
#: The route list's window over the leftover rows (its ``page`` is the PgUp/PgDn
|
||
#: stride), and whether any rows are hidden by it (gates the footer's scroll atom).
|
||
self._list = ListWindow()
|
||
self._list_hidden = False
|
||
#: The highlighted route's pathline horizontal scroll (cells shifted in, ``←/→``);
|
||
#: resets whenever the cursor leaves that row (see :data:`_HSHIFT_RESET_ACTIONS`).
|
||
self._hshift = 0
|
||
#: The Info tab's key lane horizontal scroll (cells shifted in, ``←/→``). Unlike the
|
||
#: route pathline's, it is not the cursor's — the key is pinned chrome in the stage,
|
||
#: no row you can land on — so moving through the action rows leaves it where the
|
||
#: reader put it; only leaving the tab resets it.
|
||
self._key_shift = 0
|
||
#: The last render width, so the footer hint can tell whether the highlighted
|
||
#: route's pathline actually overflows (nothing does before the first paint).
|
||
self._last_width = 0
|
||
|
||
# --- input -----------------------------------------------------------------
|
||
|
||
@property
|
||
def footer_hint(self) -> str: # type: ignore[override]
|
||
"""The hint line: tab switch, move, open, whatever ``←→`` reads here, Esc last.
|
||
|
||
The scroll atom folds ``PgUp/PgDn`` and ``←→`` into one when both apply (a busy node's
|
||
list is windowed *and* its highlighted route overflows) rather than stacking two atoms
|
||
and risking the 72-column hint budget. On the Info tab the ``←→`` atom names its
|
||
subject — the key is the one thing there that scrolls, and the cursor is elsewhere.
|
||
Each atom is gated on there actually being something to scroll, so the line never
|
||
advertises a key that would do nothing.
|
||
"""
|
||
parts: list[str] = []
|
||
if len(self._tabs) >= 2:
|
||
parts.append("Tab/⇧Tab switch")
|
||
parts.extend(("↑↓ move", "Enter open"))
|
||
hscroll = self._selected_route_overflows()
|
||
if self._list_hidden and hscroll:
|
||
parts.append("PgUp/PgDn/←→ scroll")
|
||
elif self._list_hidden:
|
||
parts.append("PgUp/PgDn scroll")
|
||
elif hscroll:
|
||
parts.append("←→ scroll")
|
||
elif self._key_overflows():
|
||
parts.append("←→ scroll key")
|
||
parts.append("Esc back")
|
||
return " · ".join(parts)
|
||
|
||
def _selected_route_overflows(self) -> bool:
|
||
"""Whether the highlighted route's pathline is too wide for the list's content lane.
|
||
|
||
Gates both the ``←→`` scroll and its footer atom — a pathline that fits has nowhere
|
||
to scroll. Measured against the last render width (``0`` before the first paint, so
|
||
nothing reads as overflowing until a real width is known).
|
||
"""
|
||
if self._last_width <= 0 or self._routes is None:
|
||
return False
|
||
tab = self._tabs[self._tab_index] if self._tabs else None
|
||
if tab is None or tab.kind != "routes":
|
||
return False
|
||
routes = self._routes.routes
|
||
if not routes or not (0 <= self._row_index < len(routes)):
|
||
return False
|
||
avail = max(1, self._last_width - _ROUTE_INDENT)
|
||
# The trailing `…` is chrome riding outside the lane (pathline.with_action_mark), so
|
||
# it is no part of what has to fit: a route exactly as wide as its lane scrolls
|
||
# nowhere, and the mark is simply the thing that goes unshown.
|
||
return cell_len(routes[self._row_index].path.plain) > avail
|
||
|
||
def _on_info_tab(self) -> bool:
|
||
"""Whether the Info tab is the one filling the stage (so its key lane is on screen)."""
|
||
tab = self._tabs[self._tab_index] if self._tabs else None
|
||
return tab is not None and tab.kind == "info"
|
||
|
||
def _key_value(self) -> Text | None:
|
||
"""The Info tab's key value — the one vital rendered as a scrolling lane, or ``None``.
|
||
|
||
Found by its label rather than its position: the block is assembled elsewhere
|
||
(:func:`open_node_detail`) and the rows around it come and go with what is known
|
||
about the node, but the row *labelled* :data:`_KEY_LABEL` is always the key.
|
||
"""
|
||
for label, value in self._info_rows:
|
||
if label == _KEY_LABEL:
|
||
return value
|
||
return None
|
||
|
||
def _key_overflows(self) -> bool:
|
||
"""Whether the key is wider than its lane — gating both ``←→`` and the footer atom.
|
||
|
||
Measured against the last render width (``0`` before the first paint, so nothing
|
||
reads as overflowing until a real width is known), exactly as the route pathline's
|
||
sibling probe is.
|
||
"""
|
||
if self._last_width <= 0 or not self._on_info_tab():
|
||
return False
|
||
value = self._key_value()
|
||
if value is None:
|
||
return False
|
||
return cell_len(value.plain) > max(1, self._last_width - _LABEL_LANE)
|
||
|
||
def selected_spec(self) -> str:
|
||
"""The forced-path spec of the currently-highlighted route (``""`` = auto).
|
||
|
||
The opener reads this after a ``trace`` token resolves, so a trace arms on whichever
|
||
route the cursor had picked rather than always the best one.
|
||
"""
|
||
routes = self._routes.routes if self._routes is not None else []
|
||
if routes:
|
||
sel = self._route_sel if 0 <= self._route_sel < len(routes) else 0
|
||
return routes[sel].spec
|
||
return ""
|
||
|
||
def consume_edge_scrub(self) -> int:
|
||
"""Scrub the panel's right edge only while the Info tab is showing its braille map."""
|
||
if self._minimap is None or not self._tabs:
|
||
return 0
|
||
return 2 if self._tabs[self._tab_index].kind == "info" else 0
|
||
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
"""Answer one key press on the page.
|
||
|
||
Switch tab, move the cursor within a tab, commit a row, page the list, scroll
|
||
whichever over-wide lane the active tab owns (the highlighted route's pathline,
|
||
the Info tab's key), or leave.
|
||
"""
|
||
focus = self._focusables()
|
||
n = len(focus)
|
||
if action in _HSHIFT_RESET_ACTIONS:
|
||
self._hshift = 0 # leaving a row abandons its scroll — each one rides its own
|
||
if action == "enter":
|
||
if n:
|
||
kind, payload = focus[self._row_index % n]
|
||
if kind == "path":
|
||
# A route row is itself the trace entry point: Enter arms a trace on the
|
||
# route it names (the opener reads :meth:`selected_spec` for the pick).
|
||
self.resolve("trace")
|
||
else:
|
||
assert isinstance(payload, _Action)
|
||
self.resolve(payload.key)
|
||
elif action == "tab":
|
||
self._switch_tab(1)
|
||
elif action == "shift_tab":
|
||
self._switch_tab(-1)
|
||
elif action == "up":
|
||
if n:
|
||
# Both ends clamp rather than wrap: the route rows scroll inside a
|
||
# window (see ListWindow), and a highlight that jumped end to end would
|
||
# take the window with it — the one move that looks like the screen
|
||
# changed under you.
|
||
self._row_index = max(0, self._row_index - 1)
|
||
self._sync_route_sel(focus)
|
||
elif action == "down":
|
||
if n:
|
||
self._row_index = min(n - 1, self._row_index + 1)
|
||
self._sync_route_sel(focus)
|
||
elif action == "pageup":
|
||
if n:
|
||
self._row_index = max(0, self._row_index - self._list.page)
|
||
self._sync_route_sel(focus)
|
||
elif action in ("pagedown", "space"):
|
||
if n:
|
||
self._row_index = min(n - 1, self._row_index + self._list.page)
|
||
self._sync_route_sel(focus)
|
||
elif action in ("home", "ctrl_home"):
|
||
if n:
|
||
self._row_index = 0
|
||
self._sync_route_sel(focus)
|
||
# On a terminal too short for the pinned layout the frame follows the cursor;
|
||
# jumping home should surface the very top of the page, header included.
|
||
self.scroll_to_top()
|
||
elif action in ("end", "ctrl_end"):
|
||
if n:
|
||
self._row_index = n - 1
|
||
self._sync_route_sel(focus)
|
||
elif action == "left":
|
||
if self._on_route_row(focus):
|
||
self._hshift = max(0, self._hshift - _HSCROLL_STEP)
|
||
elif self._key_overflows():
|
||
self._key_shift = max(0, self._key_shift - _HSCROLL_STEP)
|
||
elif action == "right":
|
||
if self._on_route_row(focus):
|
||
self._hshift += _HSCROLL_STEP # clamped to the pathline's tail at render
|
||
elif self._key_overflows():
|
||
self._key_shift += _HSCROLL_STEP # clamped to the key's tail at render
|
||
elif action == "escape":
|
||
self.resolve(CANCEL)
|
||
|
||
def _on_route_row(self, focus: list[tuple[str, object]]) -> bool:
|
||
"""Whether the cursor currently rests on a route row (as opposed to an action row)."""
|
||
return bool(focus) and focus[self._row_index % len(focus)][0] == "path"
|
||
|
||
def cursor_line(self) -> int | None:
|
||
"""The highlighted row's body line, which the frame keeps in view.
|
||
|
||
That only matters on a terminal too short for the pinned layout's minimums.
|
||
"""
|
||
return self._cursor
|
||
|
||
def _switch_tab(self, delta: int) -> None:
|
||
"""Move the active tab, resetting the cursor and list window to that tab's top."""
|
||
if len(self._tabs) < 2:
|
||
return
|
||
self._tab_index = (self._tab_index + delta) % len(self._tabs)
|
||
self._row_index = 0
|
||
self._route_sel = 0
|
||
self._list.top = 0
|
||
self._hshift = 0
|
||
self._key_shift = 0
|
||
self.scroll_to_top()
|
||
self._sync_route_sel(self._focusables())
|
||
|
||
def _sync_route_sel(self, focus: list[tuple[str, object]]) -> None:
|
||
"""Point the graph highlight at the route under the cursor, when it rests on one."""
|
||
if focus:
|
||
kind, payload = focus[self._row_index % len(focus)]
|
||
if kind == "path":
|
||
assert isinstance(payload, int)
|
||
self._route_sel = payload
|
||
|
||
def _focusables(self) -> list[tuple[str, object]]:
|
||
"""The active tab's cursor stops: ``("path", route_idx)`` and ``("action", _Action)``.
|
||
|
||
On the Routes tab the route rows come first — each is itself the trace entry
|
||
point — with the auto-route Trace action standing in only when there are no routes
|
||
to list; the Info tab offers its own actions (Open full map, Time machine). The
|
||
shared tail (Back) closes every tab.
|
||
"""
|
||
tab = self._tabs[self._tab_index] if self._tabs else None
|
||
focus: list[tuple[str, object]] = []
|
||
if tab is not None and tab.kind == "routes" and self._routes is not None:
|
||
focus.extend(("path", i) for i in range(len(self._routes.routes)))
|
||
if not self._routes.routes and self._trace_action is not None:
|
||
focus.append(("action", self._trace_action))
|
||
elif tab is not None and tab.kind == "info":
|
||
focus.extend(("action", a) for a in self._info_actions)
|
||
return focus
|
||
|
||
# --- rendering -------------------------------------------------------------
|
||
|
||
def render_body(self, width: int) -> list[str]:
|
||
"""Render the pinned chrome, the sized-to-fit stage, and the windowed cursor rows.
|
||
|
||
The viewport the frame recorded (:meth:`~meshterm.ui.tui.screen.Screen.note_viewport`)
|
||
is split three ways each paint: the pinned chrome (identity header, tab strip) and
|
||
the action rows take their fixed lines first, the stage takes what it needs of the
|
||
rest (the route graph's row ceiling shrinks to fit, the Info tab's location preview
|
||
grows into what its rows spare), and the route list windows itself into the leftover
|
||
lines — so nothing here ever pushes the graph or the actions off the screen.
|
||
"""
|
||
viewport = self._scroll_viewport
|
||
self._last_width = width
|
||
focus = self._focusables()
|
||
if focus:
|
||
self._row_index %= len(focus)
|
||
self._cursor = None
|
||
self._list_hidden = False
|
||
|
||
# -- pinned chrome: the identity header, then the tab strip (a lone tab collapses
|
||
# to one line; a multi-tab strip boxes the active tab across three). The budget
|
||
# below is struck from ``len(lines)`` after this, so either shape sizes correctly.
|
||
lines: list[str] = []
|
||
lines.extend(render_lines(self._header, width))
|
||
if self._tabs:
|
||
lines.extend([""] * _TAB_AIR)
|
||
lines.extend(
|
||
render_lines(
|
||
tab_strip([t.name for t in self._tabs], self._tab_index, width),
|
||
width,
|
||
no_wrap=True,
|
||
)
|
||
)
|
||
|
||
# The action rows are fixed chrome too — a leading blank, one line per row —
|
||
# struck before the stage draws so it can size against them.
|
||
actions = [payload for _kind, payload in focus if _kind == "action"]
|
||
action_lines = (1 + len(actions)) if actions else 0
|
||
|
||
# -- the stage, sized to what the viewport leaves, closed by a faint rule.
|
||
route_blocks: list[list[str]] = []
|
||
tab = self._tabs[self._tab_index] if self._tabs else None
|
||
if tab is not None:
|
||
if tab.kind == "info":
|
||
budget = viewport - len(lines) - action_lines - 1 # the rule's line
|
||
lines.extend(self._info_stage(width, budget))
|
||
else:
|
||
route_blocks = [
|
||
self._route_row_lines(route, i == self._row_index, width)
|
||
for i, route in enumerate(self._routes.routes if self._routes else [])
|
||
]
|
||
if not route_blocks:
|
||
# The bare note pays for its own air under the strip — where the
|
||
# platform affords the strip any air at all.
|
||
lines.extend([""] * _TAB_AIR)
|
||
budget = viewport - len(lines) - action_lines - 1 # the rule's line
|
||
lines.extend(self._routes_stage(width, budget, route_blocks))
|
||
# The rule closes the stage, so the drawn view and the rows below it read as
|
||
# separate bands rather than one run-on column.
|
||
lines.append(render_to_ansi(Text("─" * width, style="faint"), width))
|
||
|
||
# -- the route list, windowed into whatever the stage left.
|
||
if route_blocks:
|
||
window = max(1, viewport - len(lines) - action_lines)
|
||
heights = [len(block) for block in route_blocks]
|
||
on_route = self._row_index if self._row_index < len(route_blocks) else None
|
||
top, count = self._list.fit_blocks(heights, window, on_route)
|
||
if top > 0:
|
||
lines.append(render_to_ansi(ListWindow.marker(top, "above"), width))
|
||
for i in range(top, top + count):
|
||
if i == self._row_index:
|
||
self._cursor = len(lines)
|
||
lines.extend(route_blocks[i])
|
||
below = len(route_blocks) - top - count
|
||
if below > 0:
|
||
lines.append(render_to_ansi(ListWindow.marker(below, "below"), width))
|
||
self._list_hidden = top > 0 or below > 0
|
||
|
||
# -- the pinned action rows (the route rows precede them in focus order).
|
||
if actions:
|
||
lines.append("")
|
||
base = len(route_blocks)
|
||
for j, payload in enumerate(actions):
|
||
assert isinstance(payload, _Action)
|
||
selected = base + j == self._row_index
|
||
if selected:
|
||
self._cursor = len(lines)
|
||
lines.append(self._action_line(payload, selected, width))
|
||
|
||
self._scroll_total = max(1, len(lines))
|
||
return lines
|
||
|
||
def _info_stage(self, width: int, budget: int) -> list[str]:
|
||
"""The vitals block and, with a fix, the location preview grown to fit.
|
||
|
||
``budget`` is the viewport lines left for the whole stage; the vitals rows never
|
||
truncate — it is the preview that flexes, taking whatever they and its caption
|
||
leave, clamped to ``[_MAP_MIN_ROWS, _MAP_MAX_ROWS]``. Every row but the key hangs
|
||
under itself when it wraps; the key holds its one line and scrolls (see
|
||
:meth:`_key_line`), so a row count the preview sizes against can't move as a reader
|
||
walks a long key.
|
||
"""
|
||
lines: list[str] = [""] * _TAB_AIR # the stage's air under the strip, where afforded
|
||
for label, value in self._info_rows:
|
||
if label == _KEY_LABEL:
|
||
lines.append(self._key_line(label, value, width))
|
||
continue
|
||
lines.extend(
|
||
render_hanging(
|
||
Text(f"{label:<{_LABEL_LANE}}", style="muted"),
|
||
value,
|
||
width,
|
||
indent=_LABEL_LANE,
|
||
)
|
||
)
|
||
if self._minimap is not None:
|
||
lines.append("")
|
||
caption = 1 if self._map_caption is not None else 0
|
||
rows = max(_MAP_MIN_ROWS, min(_MAP_MAX_ROWS, budget - len(lines) - caption))
|
||
lines.extend(self._minimap.render(width, rows))
|
||
if self._map_caption is not None:
|
||
lines.extend(render_lines(self._map_caption, width, no_wrap=True))
|
||
return lines
|
||
|
||
def _key_line(self, label: str, value: Text, width: int) -> str:
|
||
"""The key vital as one scrolling lane: the label, then the window ``←→`` settled on.
|
||
|
||
A full public key is 64 hex digits and the lane is whatever the terminal leaves after
|
||
the label — 63 cells at the 72-column standard, fewer on the PicoCalc — so the key
|
||
never fits, and wrapping it would spend a whole second line on the handful of digits
|
||
that fell off. Instead the lane shows a window of it and :attr:`_key_shift` slides
|
||
that window (``←→``, :data:`_HSCROLL_STEP` cells a press), with a faint
|
||
:data:`_MORE_MARK` at whichever edge the key continues past — the affordance that says
|
||
the row is a viewport, not the whole value.
|
||
|
||
The marks sit *beside* the window rather than over its first cell, so the digits keep
|
||
their byte alignment at every shift (the step is four whole bytes): a key always reads
|
||
in byte pairs, and a window starting mid-byte would spell it wrong. The shift is
|
||
clamped here, against the width actually being drawn, to the first step that brings
|
||
the key's tail into the lane — so scrolling can never run off into empty lane, and a
|
||
resize only ever pulls an out-of-range shift back in.
|
||
"""
|
||
lane = Text(no_wrap=True)
|
||
lane.append(f"{label:<{_LABEL_LANE}}", style="muted")
|
||
avail = max(1, width - _LABEL_LANE)
|
||
total = cell_len(value.plain)
|
||
if total <= avail:
|
||
self._key_shift = 0
|
||
lane.append_text(value)
|
||
return render_to_ansi(lane, width, no_wrap=True)
|
||
# The lane gives up a cell to the left mark the moment it scrolls, so its last window
|
||
# spans ``avail - 1`` cells; the ceiling is the first whole step that reaches the end.
|
||
steps = -(-(total - (avail - 1)) // _HSCROLL_STEP)
|
||
self._key_shift = max(0, min(self._key_shift, steps * _HSCROLL_STEP))
|
||
shift = self._key_shift
|
||
left = 1 if shift else 0
|
||
inner = avail - left
|
||
right = 1 if shift + inner < total else 0
|
||
if left:
|
||
lane.append(_MORE_MARK, style="muted")
|
||
lane.append_text(crop_cells(value, shift, inner - right))
|
||
if right:
|
||
lane.append(_MORE_MARK, style="muted")
|
||
return render_to_ansi(lane, width, no_wrap=True)
|
||
|
||
def _routes_stage(self, width: int, budget: int, route_blocks: list[list[str]]) -> list[str]:
|
||
"""The route-graph fan, sized to fit — or the muted note when there is no evidence.
|
||
|
||
``budget`` is what the viewport leaves for the stage *and* the route list together;
|
||
the graph's row ceiling is what remains after its caption/legend and the list's
|
||
guaranteed minimum (the lesser of :data:`_LIST_MIN_LINES` and what the rows actually
|
||
need), floored at :data:`_GRAPH_MIN_ROWS` — so the graph compresses its lane pitch
|
||
before the list would lose its window, and a busy node's fan only spreads out on a
|
||
terminal tall enough to afford it. The selected route draws white with its off-route
|
||
labels faded, as ever.
|
||
|
||
Where the caption is dropped (:data:`_GRAPH_CAPTION` — the PicoCalc), the fan is
|
||
drawn one row *over* its ceiling and its leading blank canvas row is peeled off
|
||
after: the renderer always centres the lane band inside two cells of end padding
|
||
while its topmost label wants only one, so that row is reliably empty and the peel
|
||
pays for the row it was granted. Both rows land back in the ceiling itself.
|
||
"""
|
||
rv = self._routes
|
||
assert rv is not None
|
||
if not rv.routes or rv.glyph_of is None:
|
||
return render_lines(Text(rv.note or "no route observed yet", style="muted"), width)
|
||
sel = self._route_sel if 0 <= self._route_sel < len(rv.routes) else 0
|
||
# The routes view is a snapshot (the opener built it once), so the whole fan —
|
||
# a multi-pass graph layout — is a pure function of the width, the row budget,
|
||
# and which route is emphasized. One slot suffices: the key only moves on a
|
||
# selection change or a resize, and then the previous layout is dead anyway.
|
||
caption_lines = (1 if _GRAPH_CAPTION else 0) + (1 if rv.legend else 0)
|
||
list_need = min(sum(len(block) for block in route_blocks), _LIST_MIN_LINES)
|
||
max_rows = max(_GRAPH_MIN_ROWS, min(_PATH_MAX_ROWS, budget - caption_lines - list_need))
|
||
stage_key = (width, max_rows, sel)
|
||
if self._stage_memo is not None and self._stage_memo[0] == stage_key:
|
||
return self._stage_memo[1]
|
||
# Priority is fixed by evidence order (route 0, the best-evidence route, is always the
|
||
# spine) so the fan's geometry never moves as the selection changes — only emphasis
|
||
# (which route draws white and on top, and which nodes keep their hue) follows the pick.
|
||
layers = [
|
||
PathLayer(
|
||
hops=route.draw,
|
||
color=_WHITE if i == sel else _GREY,
|
||
priority=len(rv.routes) - i,
|
||
emphasis=1 if i == sel else 0,
|
||
)
|
||
for i, route in enumerate(rv.routes)
|
||
]
|
||
# A node keeps its hue only while it sits on the selected route, and everything off it
|
||
# — marker and label — recedes to grey. That is the widget's own rule now (see
|
||
# :func:`~meshterm.ui.pathgraph.render_path_graph`), decided in the graph's own id
|
||
# space, so a route reaching a relay by a short hash still lights the wide marker its
|
||
# hop is folded into.
|
||
label_rgb_of = rv.label_rgb_of
|
||
assert label_rgb_of is not None
|
||
|
||
lines = list(
|
||
render_path_graph(
|
||
layers,
|
||
width,
|
||
glyph_of=rv.glyph_of,
|
||
label_of=rv.label_of, # type: ignore[arg-type]
|
||
label_rgb_of=label_rgb_of,
|
||
max_rows=max_rows if _GRAPH_CAPTION else max_rows + 1,
|
||
)
|
||
)
|
||
if _GRAPH_CAPTION:
|
||
caption = Text("node → you, as heard · white = selected route", style="faint")
|
||
lines.extend(render_lines(caption, width, no_wrap=True))
|
||
else:
|
||
while lines and not _SGR.sub("", lines[0]).strip():
|
||
lines.pop(0)
|
||
if rv.legend:
|
||
lines.extend(render_lines(node_type_legend(), width, no_wrap=True))
|
||
self._stage_memo = (stage_key, lines)
|
||
return lines
|
||
|
||
def _route_row_lines(self, route: _Route, selected: bool, width: int) -> list[str]:
|
||
"""One route row: a ``❯``-pointed pathline, its context hanging on the line below.
|
||
|
||
The route keeps its per-node colours even when selected (the pointer, and the white
|
||
line in the graph above, carry the selection) — unlike the plain action rows, which
|
||
take the highlight, since here the colour *is* the content. The trailing ``…`` is the
|
||
app-wide opens-further-prompts mark: Enter on the row arms a trace on it. It rides
|
||
*outside* the lane (:func:`~meshterm.ui.pathline.with_action_mark`) — chrome buys no
|
||
cells off the route, so a chain that fits stays whole and closes on its rounded cap
|
||
instead of cracking to make room for two cells of hint, and the mark is what goes
|
||
unshown where the lane is full. The
|
||
pathline never hop-wraps — it is one line, cropped: the highlighted row rides
|
||
:attr:`_hshift` (``←→``, see :meth:`handle`) so a named chain wider than the lane can
|
||
still be read to its end, under a :func:`~meshterm.ui.pathline.cut_mark` at whichever
|
||
edge the line continues past — a chip broken off on its own colour where the route is
|
||
drawn in chips, the faint ``…`` where it is drawn in arrows. Same window the Info
|
||
tab's key lane scrolls in, and the same one the Message paths rows do. That scroll is
|
||
what buys the row its *names*
|
||
(:func:`_route_line`): a hop chain spelled out in places outruns a lane far sooner
|
||
than one spelled in hash bytes, and the answer is to read it, not to shorten it. Every
|
||
other row is simply cut to the lane (:func:`~meshterm.ui.pathline.cut_to`, cracked or
|
||
ellipsized by the same rule), and the context line under any row — plain prose, never
|
||
a path — ellipsizes: only the row you are on can scroll, and it is the only one whose
|
||
end you are asking to see. The context — hop count, bottleneck SNR, sample count, the
|
||
``★ best`` / ``device route`` tag — always leads with the count, so every row is two
|
||
lines tall and the list stops changing height as the cursor walks it.
|
||
"""
|
||
indent = _ROUTE_INDENT
|
||
avail = max(1, width - indent)
|
||
pointer = Text("❯ " if selected else " ", style="cursor" if selected else "")
|
||
marked = route.path
|
||
if selected and self._hshift:
|
||
total = cell_len(marked.plain)
|
||
# A scrolled row has given up a cell to its left mark, so its last window spans
|
||
# ``avail - 1``; clamp to the first whole step that brings the tail inside it —
|
||
# short of that the right mark would still be drawn at the far end, promising a
|
||
# remainder the keys can no longer reach. (The key lane clamps the same way.)
|
||
steps = -(-max(0, total - (avail - 1)) // _HSCROLL_STEP)
|
||
self._hshift = max(0, min(self._hshift, steps * _HSCROLL_STEP))
|
||
shift = self._hshift
|
||
# The marks are chrome inside the window, not extra width: each costs the lane a
|
||
# cell, so the crop is measured after they are known — else the line drawn under a
|
||
# right-hand ``…`` would run a cell past the row.
|
||
left = 1 if shift else 0
|
||
inner = avail - left
|
||
right = 1 if shift + inner < total else 0
|
||
window = max(1, inner - right)
|
||
path = Text()
|
||
if left:
|
||
path.append_text(cut_mark(marked, shift, ELIDE_HEAD))
|
||
path.append_text(crop_cells(marked, shift, window))
|
||
if right:
|
||
path.append_text(cut_mark(marked, shift + window - 1, ELIDE_TAIL))
|
||
else:
|
||
path = cut_to(marked, avail)
|
||
path = with_action_mark(path, avail)
|
||
path.no_wrap = True
|
||
line1 = Text()
|
||
line1.append_text(pointer)
|
||
line1.append_text(path)
|
||
lines = [render_to_ansi(line1, width)]
|
||
if route.context.plain:
|
||
context = route.context.copy()
|
||
context.no_wrap = True
|
||
context.overflow = "ellipsis"
|
||
context.truncate(avail)
|
||
line2 = Text(" " * indent)
|
||
line2.append_text(context)
|
||
lines.append(render_to_ansi(line2, width))
|
||
return lines
|
||
|
||
def _action_line(self, action: _Action, selected: bool, width: int) -> str:
|
||
"""One action row: ``❯`` + icon + label, the whole row lit when it is the cursor's."""
|
||
text = Text("❯ " if selected else " ", style="cursor" if selected else "")
|
||
# The icon lane is decoration a platform may drop whole (command_icon); the label
|
||
# is what names the action, so an emptied lane simply gives it the cells. A tinted
|
||
# icon carries a claim the decoration can't take with it, though — a red 🗑 is how a
|
||
# destructive row announces itself — so where the lane goes, the tint lands on the
|
||
# label instead. Same move as :func:`~meshterm.ui.menus.marked_label` makes for a
|
||
# menu row, for the same reason: a delete must not read like any other action.
|
||
#
|
||
# Padded to the page's own lane rather than written as `icon + " "`: this page mixes
|
||
# a one-cell 🗑 with two-cell siblings, and the unpadded form started its label a
|
||
# column early (JP, 2026-09-01).
|
||
mark = menus.icon_mark(action.glyph, action.glyph_style, self._icon_lane)
|
||
text.append_text(mark)
|
||
text.append(action.label, style="" if mark.plain else action.glyph_style)
|
||
if selected:
|
||
text.style = "cursor"
|
||
text.no_wrap = True
|
||
text.truncate(width, overflow="ellipsis")
|
||
return render_to_ansi(text, width)
|
||
|
||
|
||
# -- geographic helpers --------------------------------------------------------
|
||
|
||
_COMPASS = ("N", "NE", "E", "SE", "S", "SW", "W", "NW")
|
||
|
||
|
||
def _distance_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||
"""Great-circle distance between two coordinates, in kilometres (haversine)."""
|
||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||
dp = math.radians(lat2 - lat1)
|
||
dl = math.radians(lon2 - lon1)
|
||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||
return 2 * EARTH_RADIUS_KM * math.asin(min(1.0, math.sqrt(a)))
|
||
|
||
|
||
def _bearing(lat1: float, lon1: float, lat2: float, lon2: float) -> str:
|
||
"""The 8-point compass direction from ``(lat1, lon1)`` toward ``(lat2, lon2)``."""
|
||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||
dl = math.radians(lon2 - lon1)
|
||
y = math.sin(dl) * math.cos(p2)
|
||
x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
|
||
deg = (math.degrees(math.atan2(y, x)) + 360) % 360
|
||
return _COMPASS[round(deg / 45) % 8]
|
||
|
||
|
||
def _range_text(lat: float, lon: float, self_lat: float | None, self_lon: float | None) -> Text:
|
||
"""A location value: the coordinates, plus range + bearing from us when we're placed."""
|
||
text = Text(f"{lat:.4f}, {lon:.4f}", style="")
|
||
if self_lat is not None and self_lon is not None:
|
||
km = _distance_km(self_lat, self_lon, lat, lon)
|
||
dist = f"{km * 1000:.0f} m" if km < 1 else f"{km:.1f} km"
|
||
text.append(f" · {dist} {_bearing(self_lat, self_lon, lat, lon)}", style="muted")
|
||
return text
|
||
|
||
|
||
def _as_float(value: object) -> float | None:
|
||
"""Best-effort float coercion for the raw lat/lon a device reports (``None`` on junk)."""
|
||
try:
|
||
return float(value) # type: ignore[arg-type]
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _located(lat: float | None, lon: float | None) -> bool:
|
||
"""Whether a coordinate is a real, plottable fix (see :func:`~meshterm.core.geo.usable_fix`).
|
||
|
||
Rejects both the 0/0 null-island a no-GPS node reports and the out-of-range nonsense a
|
||
misconfigured companion sometimes advertises (``lat -97, lon -1042`` seen in the wild),
|
||
either of which would drop the location preview onto an all-black off-world view.
|
||
"""
|
||
return lat is not None and lon is not None and usable_fix(lat, lon)
|
||
|
||
|
||
# -- data gathering + the action loop -----------------------------------------
|
||
|
||
|
||
async def open_node_detail(
|
||
ctx: AppContext, contact: Contact | None, *, manage: bool = True
|
||
) -> bool:
|
||
"""Open the Node detail page for a contact (or our own node) and run its action loop.
|
||
|
||
Assembles the page from stored history, the device's contacts, and the observed
|
||
topology — identity, reception stats, a location preview, and the observed routes — then
|
||
loops: show the page, run whatever action the user commits (trace, full map, time
|
||
machine), and show it again, until Esc backs out. This is the same show/act/reshow loop
|
||
the Time Machine and Contacts list use.
|
||
|
||
**Three actions end the loop instead of returning to it**, because each leaves a page
|
||
describing a state that no longer holds: archiving the contact off the device, restoring
|
||
it, and deleting it outright. All three are *contact management*, they are grouped last
|
||
on the Info tab, and the page offers exactly the ones that make sense — a live contact
|
||
can be archived or deleted, an archived one restored or deleted.
|
||
|
||
``manage`` is how a caller says the page is being opened to *look*, not to act. The
|
||
purge preview passes ``False``: a screen whose whole job is choosing what to archive
|
||
should not also hand out a second, singular way to archive — or a delete — from inside
|
||
its own candidate list. The rule is that management verbs belong to the list a contact
|
||
actually lives in, never to a page opened out of a list that is about to act on it
|
||
wholesale.
|
||
|
||
Args:
|
||
ctx: The shared application context (must be running the interactive TUI).
|
||
contact: The contact to detail, or ``None`` for our own node (an identity-and-ledger
|
||
page — we never overhear ourselves, so there is no reception history to show).
|
||
manage: Whether to offer the contact-management actions (archive, restore, delete).
|
||
``False`` draws none of them, and the page can then only ever return ``False``.
|
||
|
||
Returns:
|
||
``True`` if the visit ended by archiving, restoring, or deleting the contact — the
|
||
caller's cue to re-read and rebuild the list it came from — ``False`` on any
|
||
ordinary exit.
|
||
|
||
Raises:
|
||
RuntimeError: If called outside the interactive menu (no full-screen session).
|
||
"""
|
||
from ..services.topology import _is_hex, build_topology
|
||
from ..services.trace_runner import (
|
||
make_name_key_resolver,
|
||
make_node_resolver,
|
||
make_node_type_resolver,
|
||
)
|
||
from ..tools.map import gather_markers
|
||
from .config_editor import show_contact_card
|
||
from .map_screen import basemap_source, open_map
|
||
from .surface import TuiUi
|
||
from .timemachine_screen import open_timemachine_node, open_timemachine_self
|
||
from .trace_screen import _collapse_trace_width, open_trace
|
||
from .widgets import route_graph_style
|
||
|
||
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
|
||
raise RuntimeError("the node detail screen is only available in the menu")
|
||
session = ctx.ui.session
|
||
|
||
you = contact is None
|
||
|
||
# -- our own node, for the "us" label, the location preview centre, and range/bearing.
|
||
try:
|
||
info = await ctx.devstate.self_info()
|
||
except Exception: # noqa: BLE001 - the page is useful without our own identity/fix
|
||
info = {}
|
||
self_name = str(info.get("name") or "this node")
|
||
self_key = str(info.get("public_key") or "")
|
||
self_lat, self_lon = _as_float(info.get("adv_lat")), _as_float(info.get("adv_lon"))
|
||
if not _located(self_lat, self_lon):
|
||
self_lat = self_lon = None
|
||
|
||
# -- the device's contacts (for name/type/key resolution) and the routing hash width.
|
||
try:
|
||
contacts = await ctx.devstate.contacts()
|
||
except Exception: # noqa: BLE001 - resolution just falls back to stored data
|
||
contacts = []
|
||
try:
|
||
mode = int(await ctx.devstate.path_hash_mode())
|
||
prefix_bytes = (mode + 1) if 0 <= mode <= 3 else 0
|
||
width_bytes = _collapse_trace_width(mode)
|
||
except Exception: # noqa: BLE001 - optional reads; sane defaults keep the page working
|
||
prefix_bytes, width_bytes = 0, 1
|
||
|
||
stored_names = ctx.repo.node_names()
|
||
# One hash→name resolver for the whole page: the route list and the route graph all name
|
||
# their hops through it (contacts first, recorder history behind).
|
||
resolve = make_node_resolver(contacts, stored_names)
|
||
|
||
if you:
|
||
node_id = (self_key.lower().removeprefix("0x")[:12]) or ""
|
||
name: str | None = self_name
|
||
key = self_key
|
||
node_type = info.get("adv_type")
|
||
lat, lon = self_lat, self_lon
|
||
else:
|
||
assert contact is not None
|
||
key = contact.public_key or contact.key_prefix or ""
|
||
node_id = key.lower().removeprefix("0x")[:12]
|
||
name = contact.name or None
|
||
node_type = contact.node_type
|
||
lat = contact.lat if _located(contact.lat, contact.lon) else None
|
||
lon = contact.lon if _located(contact.lat, contact.lon) else None
|
||
|
||
# -- reception stats + first-heard from the recorder's history.
|
||
heard = {n.node: n for n in ctx.repo.heard_nodes() if n.node}
|
||
hn = heard.get(node_id)
|
||
firsts = {n: when for n, _n, when in ctx.repo.first_seen()}
|
||
first_heard = firsts.get(node_id)
|
||
if you:
|
||
hn = None # we never overhear ourselves — no reception history to show
|
||
# A node with a fix but no observation-derived location still shows the map — but only
|
||
# when that overheard fix is itself real (a no-GPS/nonsense advert never places it).
|
||
if lat is None and hn is not None and hn.has_location and _located(hn.lat, hn.lon):
|
||
lat, lon = hn.lat, hn.lon
|
||
|
||
label = name or node_id or key or "?"
|
||
|
||
# -- identity header.
|
||
if you:
|
||
glyph, glyph_style = _SELF_GLYPH
|
||
name_hue = "you"
|
||
else:
|
||
glyph, glyph_style = _NODE_GLYPHS.get(node_type, _DEFAULT_GLYPH)
|
||
name_hue = name_style(name, key) if name else "muted"
|
||
header = Text()
|
||
header.append(f"{glyph} ", style=glyph_style)
|
||
header.append(name or "unknown", style=name_hue)
|
||
type_label = NODE_TYPE_LABELS.get(node_type) if node_type is not None else None
|
||
if you:
|
||
header.append(" your node", style="muted")
|
||
elif type_label:
|
||
header.append(f" {type_label}", style="muted")
|
||
|
||
# -- the observed topology: the suggested best path and the routes to draw.
|
||
target_hash = key.lower().removeprefix("0x")
|
||
target_hash = target_hash if (not you and _is_hex(target_hash)) else None
|
||
topo = build_topology(
|
||
self_id=(self_key.lower().removeprefix("0x")[:12]) or "local",
|
||
contacts=contacts,
|
||
trace_paths=ctx.repo.trace_paths(),
|
||
packet_paths=ctx.repo.packet_paths(),
|
||
neighbour_links=ctx.repo.neighbour_links(),
|
||
)
|
||
device_route: tuple[str, ...] | None = None
|
||
if not you and contact is not None and contact.route_hops is not None:
|
||
device_route = tuple(topo.canonical(h) or h for h in contact.route_hops)
|
||
canonical_target = (topo.canonical(target_hash) or target_hash[:12]) if target_hash else None
|
||
suggested = topo.suggested(canonical_target) if canonical_target else None
|
||
scenarios = (
|
||
topo.scenarios(canonical_target, device_route=device_route) if canonical_target else []
|
||
)
|
||
|
||
# -- the info block (identity + how-heard + where; the routes fold into the Routes tab).
|
||
info_rows: list[tuple[str, Text]] = []
|
||
if key:
|
||
info_rows.append(("key", highlighted_hash(key, prefix_bytes, known=bool(name))))
|
||
else:
|
||
info_rows.append(("key", Text("?", style="muted")))
|
||
if not you:
|
||
# A contact's heard time already arrives merged with our reception history (see
|
||
# ``DeviceState._merge_heard``), so it is the later of the two and is what the
|
||
# contact list's lane shows — take it, and the two surfaces can never disagree about
|
||
# one node. Only a node that is no contact, or one whose merge could not read our
|
||
# history, falls back to the raw reception stat.
|
||
heard_at = contact.last_seen if contact else None
|
||
secs = _age_seconds(heard_at or (hn.last_seen if hn else None))
|
||
heard_val = Text(format_ago(secs), style=_recency_style(secs))
|
||
if first_heard is not None:
|
||
heard_val.append(f" · first {first_heard.astimezone():%b %d %Y}", style="muted")
|
||
info_rows.append(("heard", heard_val))
|
||
# A node never overheard reads a faint em-dash, as the contact list draws it.
|
||
packets = Text(str(hn.count)) if hn else Text("—", style="faint")
|
||
info_rows.append(("packets", packets))
|
||
signal = _signal_row(hn)
|
||
if signal is not None:
|
||
info_rows.append(("signal", signal))
|
||
if lat is not None and lon is not None:
|
||
info_rows.append(("where", _range_text(lat, lon, self_lat, self_lon)))
|
||
|
||
# -- the location preview (only when the node advertised a fix).
|
||
minimap: MiniMap | None = None
|
||
map_caption: Text | None = None
|
||
markers: list = []
|
||
if lat is not None and lon is not None:
|
||
try:
|
||
markers = await gather_markers(ctx)
|
||
except Exception: # noqa: BLE001 - a preview of just this node is still useful
|
||
markers = []
|
||
source = basemap_source(ctx)
|
||
try:
|
||
max_zoom = await asyncio.to_thread(lambda: source.max_zoom)
|
||
except Exception: # noqa: BLE001 - offline: markers on a blank grid
|
||
max_zoom = 14
|
||
minimap = MiniMap(
|
||
session,
|
||
source,
|
||
max_zoom,
|
||
center_lat=lat,
|
||
center_lon=lon,
|
||
zoom=min(13, max_zoom),
|
||
markers=markers,
|
||
)
|
||
map_caption = Text(f"{label} · centred here", style="faint")
|
||
|
||
# -- the "routes heard" route list + graph (node → us), best-evidence path white.
|
||
routes_view: _RoutesView | None = None
|
||
if not you:
|
||
routes_view = _routes_view(
|
||
topo,
|
||
scenarios,
|
||
suggested,
|
||
device_route,
|
||
canonical_target,
|
||
target_hash,
|
||
width_bytes,
|
||
resolve=resolve,
|
||
type_of=make_node_type_resolver(contacts),
|
||
key_of=make_name_key_resolver(contacts, stored_names),
|
||
style=route_graph_style,
|
||
self_name=self_name,
|
||
node_label=label,
|
||
name_key=key,
|
||
node_known=name is not None,
|
||
hash_bytes=prefix_bytes,
|
||
)
|
||
|
||
# -- the tabs (Info always; Routes only when there is a routes view) and their actions.
|
||
tabs: list[_Tab] = [_Tab(name="Info", kind="info")]
|
||
if routes_view is not None:
|
||
tabs.append(_Tab(name="Routes", kind="routes"))
|
||
|
||
info_actions: list[_Action] = []
|
||
if minimap is not None:
|
||
info_actions.append(_Action("map", "🌍", "", "Open full map"))
|
||
if you:
|
||
info_actions.append(_Action("timemachine", "⏳", "", "Time machine — your activity"))
|
||
elif hn is not None:
|
||
info_actions.append(
|
||
_Action("timemachine", "⏳", "", f"Time machine — {hn.count} receptions")
|
||
)
|
||
# The share card encodes the full 32-byte key; a prefix-only contact (heard but never
|
||
# synced from the device) has nothing scannable to offer, so the row only shows when
|
||
# the whole key is known.
|
||
full_key = key.lower().removeprefix("0x")
|
||
if len(full_key) == 64 and _is_hex(full_key):
|
||
info_actions.append(_Action("share", "📱", "", "Share contact — QR / link"))
|
||
# -- contact management, the page's last group and the only actions that end the visit.
|
||
# Withheld entirely when the caller opened the page to look rather than to act (see
|
||
# ``manage``), and never offered for our own node or for a contact carrying no key at
|
||
# all — there would be nothing to address the write by.
|
||
archived = manage and _is_archived(ctx, contact, self_key)
|
||
manageable = (
|
||
manage
|
||
and not you
|
||
and contact is not None
|
||
and bool(contact.public_key or contact.key_prefix)
|
||
)
|
||
if manageable:
|
||
# The constructive verb first, then the destructive one, so the row a mistaken press
|
||
# is likeliest to land on is the recoverable one. Archive and restore are the two
|
||
# halves of one reversible move, and take the ``💾``/``📂`` pair the lexicon already
|
||
# gives put-it-away and bring-it-back; only a contact with a full key can be written
|
||
# back to the device, so a prefix-only contact is never offered the archive that
|
||
# would strand it.
|
||
if archived:
|
||
info_actions.append(_Action("restore", "📂", "ok", "Restore to device"))
|
||
elif len((contact.public_key or "").lower().removeprefix("0x")) == 64:
|
||
info_actions.append(_Action("archive", "💾", "", "Archive contact"))
|
||
info_actions.append(_Action("remove", "🗑", "err", "Delete contact…"))
|
||
try:
|
||
adv_type = int(node_type) if node_type is not None else 1
|
||
except (TypeError, ValueError):
|
||
adv_type = 1
|
||
# With routes listed, each route row is its own trace entry point (Enter arms it); the
|
||
# dedicated action only stands in when there is no route evidence to list.
|
||
trace_action: _Action | None = None
|
||
if not you and node_id and not (routes_view is not None and routes_view.routes):
|
||
trace_action = _Action("trace", "🎯", "", "Trace — auto route …")
|
||
title = f"Node — {label}" if not you else f"Node — {label} (you)"
|
||
# Whether the visit ended by deleting the contact — the caller's cue to rebuild its list.
|
||
contact_removed = False
|
||
# One screen for the whole visit. The page is a *hub* — every action on it opens
|
||
# something else and comes back — so it stays pushed while each of them runs
|
||
# (``session.stay``) instead of being rebuilt per round. Rebuilding cost the open tab
|
||
# and the cursor every time: Tab to Routes, pick a route, trace it, come back, and you
|
||
# were on Info at the top again, two keypresses from the route you were working
|
||
# through. Keeping the object keeps all of it. It also means the peers below *nest*
|
||
# above this page rather than replacing it, so Esc from a trace lands back here; ^W is
|
||
# what leaves the whole excursion at once.
|
||
screen = NodeDetailScreen(
|
||
title=title,
|
||
header=header,
|
||
info_rows=info_rows,
|
||
tabs=tabs,
|
||
minimap=minimap,
|
||
map_caption=map_caption,
|
||
routes=routes_view,
|
||
info_actions=info_actions,
|
||
trace_action=trace_action,
|
||
)
|
||
async with session.stay(screen) as visit:
|
||
while True:
|
||
action = await visit.result()
|
||
if action is CANCEL or action is None:
|
||
break
|
||
if action == "trace":
|
||
await open_trace(ctx, name or key, initial_spec=screen.selected_spec())
|
||
elif action == "map":
|
||
if markers:
|
||
# Open centred on this node — the very spot the inline preview showed —
|
||
# not wherever the global map was last left — with the find filter seeded
|
||
# to it, so this node lights among the rest. Seed only when its label
|
||
# actually matches a marker; a needle nothing matches would dim
|
||
# everything. (The map action only exists when the node has a fix, so
|
||
# lat/lon are set.)
|
||
needle = label.casefold()
|
||
await open_map(
|
||
ctx,
|
||
markers,
|
||
focus=(lat, lon),
|
||
find=(
|
||
label if any(needle in m.label.casefold() for m in markers) else None
|
||
),
|
||
)
|
||
elif action == "share":
|
||
await show_contact_card(ctx, label, full_key, adv_type)
|
||
elif action == "timemachine":
|
||
if you:
|
||
await open_timemachine_self(ctx)
|
||
else:
|
||
await open_timemachine_node(ctx, node_id, label)
|
||
elif action == "restore":
|
||
# A restore writes the contact back and ends the visit for the same reason a
|
||
# deletion does: the page was opened from the Archived list, and the row it
|
||
# was opened from is about to stop existing there.
|
||
assert contact is not None # the row only exists for a real contact
|
||
if await _restore_archived(ctx, contact, self_key, label):
|
||
contact_removed = True
|
||
break
|
||
elif action == "archive":
|
||
assert contact is not None
|
||
if await _archive_contact(ctx, contact, self_key, label):
|
||
contact_removed = True
|
||
break
|
||
elif action == "remove":
|
||
# The confirm floats over this page, which is already the backdrop — the
|
||
# reader confirms against the node they are looking at. A removal ends the
|
||
# visit: there is no contact left to detail, and the list we return to
|
||
# rebuilds without it.
|
||
assert contact is not None # the row only exists for a real contact
|
||
contact_removed = await _remove_contact(ctx, contact, self_key, label)
|
||
if contact_removed:
|
||
break
|
||
if minimap is not None:
|
||
# The preview's braille may have smeared the terminal (double-width fallback
|
||
# glyphs prompt_toolkit's diff can't see); force one clean repaint of the list
|
||
# underneath, exactly as the full map does on the way out.
|
||
session.request_full_repaint()
|
||
return contact_removed
|
||
|
||
|
||
def _is_archived(ctx: AppContext, contact: Contact | None, self_key: str) -> bool:
|
||
"""Whether this contact was swept off the device and is being kept by MeshTerm.
|
||
|
||
Read straight from the contact store rather than passed in, so the page answers for
|
||
itself wherever it was opened from — the Contacts list's ``Archived`` section, a map
|
||
marker, a route row — instead of only where the caller happened to know.
|
||
"""
|
||
store = getattr(ctx, "contact_store", None)
|
||
dev_pub = (self_key or "").lower().removeprefix("0x")
|
||
if store is None or contact is None or not dev_pub or not contact.public_key:
|
||
return False
|
||
key = contact.public_key.lower().removeprefix("0x")
|
||
return any(c.public_key == key for c in store.archived(dev_pub))
|
||
|
||
|
||
async def _archive_contact(ctx: AppContext, contact: Contact, self_key: str, label: str) -> bool:
|
||
"""Confirm and archive one contact off the device; ``True`` once it is gone from the radio.
|
||
|
||
The single-contact counterpart to the bulk sweep (see
|
||
:func:`~meshterm.ui.purge_screen.purge_contacts`), doing exactly what the sweep does to
|
||
each of its victims: remove from the device, then record it in the cross-session store
|
||
with an archive stamp so nothing is actually lost.
|
||
|
||
The confirm is **amber, not red, and takes no typing**. Two escalating caution tiers
|
||
exist for two different costs, and this one is recoverable: the contact keeps its key,
|
||
its reception history and its transcripts, the Archived list is one row on the Contacts
|
||
screen away, and a restore is a single write. Red and a typed word are for
|
||
:func:`_remove_contact`, which is the one that cannot be undone.
|
||
|
||
Args:
|
||
ctx: The shared application context (interactive menu; the page is the backdrop).
|
||
contact: The contact to archive, addressed by the key it carries.
|
||
self_key: The device's own public key (hex) — how the contact store scopes this
|
||
device's remembered contacts.
|
||
label: How the node is named on the page, for the prompt and the failure notice.
|
||
|
||
Returns:
|
||
``True`` if the contact was archived, ``False`` if the user cancelled or the device
|
||
refused. A device that has no such contact is not a refusal: the archive finishes on
|
||
our side, since being off the radio is the state it was asking for.
|
||
"""
|
||
from .surface import TuiUi
|
||
|
||
assert isinstance(ctx.ui, TuiUi) # guaranteed by open_node_detail
|
||
session = ctx.ui.session
|
||
|
||
if not await ctx.ui.dialog(
|
||
f"Archive {label}? It comes off this device's contact list, freeing a slot for a "
|
||
"new one. MeshTerm keeps it — with its key, reception history and messages — and "
|
||
"you can restore it at any time.",
|
||
[("Cancel", False), ("Archive", True)],
|
||
title="Archive contact",
|
||
default=1,
|
||
danger=True,
|
||
):
|
||
return False
|
||
|
||
device = await ctx.device()
|
||
try:
|
||
await device.remove_contact(contact)
|
||
except ContactNotOnDeviceError:
|
||
# The radio doesn't hold it, which is where the archive was taking it anyway: the
|
||
# store write below is the whole of the remaining work.
|
||
ctx.log.debug("contacts: %s was not on the device; archiving ours", contact.name)
|
||
except Exception as exc: # noqa: BLE001 - a refused removal is reported, not raised
|
||
ctx.log.debug("contacts: archive failed for %s: %s", contact.name, exc)
|
||
await session.message_dialog(
|
||
Text(f"✗ couldn't archive {label} — {exc}", style="err"),
|
||
title="Archive contact",
|
||
)
|
||
return False
|
||
|
||
dev_pub = (self_key or "").lower().removeprefix("0x")
|
||
if ctx.contact_store is not None and dev_pub and contact.public_key:
|
||
ctx.contact_store.archive(dev_pub, contact, when=int(time.time()))
|
||
ctx.devstate.invalidate_contacts()
|
||
return True
|
||
|
||
|
||
async def _restore_archived(ctx: AppContext, contact: Contact, self_key: str, label: str) -> bool:
|
||
"""Write an archived contact back onto the device; ``True`` once it is live again.
|
||
|
||
The inverse of the Contacts sweep, one contact at a time (see
|
||
:mod:`~meshterm.ui.purge_screen`). The device write comes **first** and the store's
|
||
archive mark is cleared only once it succeeded — a failed write must never leave a
|
||
contact listed as live on a radio that doesn't hold it, which would be a row you cannot
|
||
message and cannot restore.
|
||
|
||
No confirm: restoring is constructive, reversible by the sweep that archived it, and
|
||
costs one contact slot. The dialog is kept for the failures, which are the only part the
|
||
reader can't infer from the list coming back with the row in it.
|
||
|
||
Args:
|
||
ctx: The shared application context (interactive menu; the page is the backdrop).
|
||
contact: The archived contact to write back.
|
||
self_key: The device's own public key (hex).
|
||
label: How the node is named on the page, for the failure notice.
|
||
|
||
Returns:
|
||
``True`` if the contact is back on the device, ``False`` if the write was refused.
|
||
"""
|
||
from .surface import TuiUi
|
||
|
||
assert isinstance(ctx.ui, TuiUi) # guaranteed by open_node_detail
|
||
session = ctx.ui.session
|
||
|
||
device = await ctx.device()
|
||
try:
|
||
await device.add_contact(contact)
|
||
except Exception as exc: # noqa: BLE001 - a refused write is reported, not raised
|
||
ctx.log.debug("contacts: restore failed for %s: %s", contact.name, exc)
|
||
await session.message_dialog(
|
||
Text(f"✗ couldn't restore {label} — {exc}", style="err"),
|
||
title="Restore contact",
|
||
)
|
||
return False
|
||
|
||
dev_pub = (self_key or "").lower().removeprefix("0x")
|
||
if ctx.contact_store is not None and dev_pub and contact.public_key:
|
||
ctx.contact_store.restore(dev_pub, contact.public_key)
|
||
ctx.devstate.invalidate_contacts()
|
||
return True
|
||
|
||
|
||
async def _remove_contact(ctx: AppContext, contact: Contact, self_key: str, label: str) -> bool:
|
||
"""Confirm and drop one contact from the device; ``True`` once it is gone.
|
||
|
||
The single-contact counterpart to the Contacts list's bulk sweep (see
|
||
:func:`~meshterm.ui.purge_screen.purge_contacts`) — but a *deletion* where that one
|
||
archives: this is the way to make MeshTerm forget a node entirely, so it removes the
|
||
contact in both places, because the list a screen sees is the *union* of the two: the device's
|
||
own contact table, and the contacts MeshTerm remembers for that device (see
|
||
:mod:`meshterm.core.contact_store`). Forget only the first and the store merges the
|
||
contact straight back on the next read, so the deletion would look like a screen that
|
||
did nothing.
|
||
|
||
Only the *contact* goes. The node's reception history, its overheard traffic and the
|
||
chat messages exchanged with it are all MeshTerm's own and are untouched — what is lost
|
||
is the device's ability to address it, which is why a chat send to a contact the
|
||
firmware no longer holds offers to write it back rather than failing (see
|
||
:func:`~meshterm.ui.chat._restore_contact`).
|
||
|
||
**Irreversible, and the confirm says so.** Nothing here can re-derive a key the store has
|
||
forgotten, so this is the one contact action with no way back — which is exactly what
|
||
separates it from :func:`_archive_contact`, whose amber confirm sits one row above it on
|
||
the same page. It wears the reserved red for data loss: Cancel on the left, the
|
||
committing Delete on the right and default.
|
||
|
||
Args:
|
||
ctx: The shared application context (interactive menu; the page is pushed as the
|
||
confirm's backdrop by the caller).
|
||
contact: The contact to delete, addressed by the key it carries.
|
||
self_key: The device's own public key (hex) — how the contact store scopes this
|
||
device's remembered contacts.
|
||
label: How the node is named on the page, for the prompt and the failure notice.
|
||
|
||
Returns:
|
||
``True`` if the contact was removed, ``False`` if the user cancelled or the device
|
||
refused (the refusal is shown, never swallowed — a contact still on the radio must
|
||
not vanish from the list). A device that has no such contact is not a refusal: the
|
||
removal finishes on our side and says so
|
||
(:class:`~meshterm.core.connection.ContactNotOnDeviceError`).
|
||
"""
|
||
from .surface import TuiUi
|
||
|
||
assert isinstance(ctx.ui, TuiUi) # guaranteed by open_node_detail
|
||
session = ctx.ui.session
|
||
|
||
if not await ctx.ui.dialog(
|
||
f"Delete {label} for good? Its key is forgotten by both this device and MeshTerm, "
|
||
"so it can't be messaged or restored — only heard again. To free the device slot "
|
||
"and keep the contact, archive it instead.",
|
||
[("Cancel", False), ("Delete", True)],
|
||
title="Delete contact",
|
||
default=1,
|
||
destructive=True,
|
||
):
|
||
return False
|
||
|
||
device = await ctx.device()
|
||
try:
|
||
await device.remove_contact(contact)
|
||
except ContactNotOnDeviceError:
|
||
# Nothing to delete on the radio — the entry only ever existed in what MeshTerm
|
||
# remembers for this device (or the firmware dropped it since we read the table).
|
||
# That is not a failed removal: the contact still goes, and the reader is told why
|
||
# the device had no part in it, because "removed" would otherwise be a claim about
|
||
# a radio that never held it.
|
||
ctx.log.debug("contacts: %s was not on the device; removing ours", contact.name)
|
||
await session.message_dialog(
|
||
Text(
|
||
f"⚠ {label} wasn't in this device's contacts — deleted from the ones "
|
||
"MeshTerm remembers for it.",
|
||
style="warn",
|
||
),
|
||
title="Delete contact",
|
||
)
|
||
except Exception as exc: # noqa: BLE001 - a refused removal is reported, not raised
|
||
ctx.log.debug("contacts: remove failed for %s: %s", contact.name, exc)
|
||
await session.message_dialog(
|
||
Text(f"✗ couldn't delete {label} — {exc}", style="err"), title="Delete contact"
|
||
)
|
||
return False
|
||
|
||
dev_pub = (self_key or "").lower().removeprefix("0x")
|
||
if ctx.contact_store is not None and dev_pub and contact.public_key:
|
||
ctx.contact_store.forget(dev_pub, contact.public_key)
|
||
# The device's table just changed under the session cache; the list we return to re-reads.
|
||
ctx.devstate.invalidate_contacts()
|
||
# No success notice: unlike the purge — whose outcome is a count nobody could predict —
|
||
# this one is self-evident. The page closes on the contact it detailed and the list
|
||
# behind it comes back without the row, which says it better than a dialog to dismiss.
|
||
return True
|
||
|
||
|
||
def _signal_row(hn) -> Text | None: # noqa: ANN001 - Optional[HeardNode]
|
||
"""Median/best reception SNR and last RSSI, or ``None`` when nothing was measured."""
|
||
if hn is None:
|
||
return None
|
||
text = Text()
|
||
if hn.median_snr is not None:
|
||
text.append("median ", style="muted")
|
||
text.append(f"{hn.median_snr:+.1f} dB", style=snr_style(hn.median_snr))
|
||
if hn.best_snr is not None:
|
||
text.append(" · best ", style="muted")
|
||
text.append(f"{hn.best_snr:+.1f} dB", style=snr_style(hn.best_snr))
|
||
if hn.last_rssi is not None:
|
||
if text.plain:
|
||
text.append(" · ", style="muted")
|
||
text.append(f"RSSI {hn.last_rssi:.0f} dBm", style="muted")
|
||
return text if text.plain else None
|
||
|
||
|
||
def _hop_hash(key: str | None, fallback: str, hash_bytes: int) -> str:
|
||
"""A key's hash at ``hash_bytes`` width, or ``fallback`` when there is no usable key."""
|
||
hex_key = (key or "").lower().removeprefix("0x")
|
||
return hex_key[: hash_bytes * 2] if hex_key else fallback
|
||
|
||
|
||
def _route_line(
|
||
node_label: str,
|
||
name_key: str,
|
||
hops_out: tuple[str, ...],
|
||
tag: str,
|
||
weakest: float | None,
|
||
samples: int,
|
||
*,
|
||
resolve, # noqa: ANN001 - NodeResolver, kept loose like the graph callbacks
|
||
node_known: bool,
|
||
self_name: str | None,
|
||
hash_bytes: int,
|
||
) -> tuple[Text, Text]:
|
||
"""One route as ``(path, context)`` — the pathline, and the line it hangs its context under.
|
||
|
||
The whole route reads left to right in the graph's own direction (contact on the left, us
|
||
on the right), so the row and the drawn line cross-read. ``path`` is one
|
||
:class:`~meshterm.ui.pathline.PathLine` drawn exactly as the Message paths dialog draws
|
||
its arrivals (JP, 2026-08-09): **hops read as names** — every hop the resolver can place
|
||
wears its contact name in that node's key-derived hue, as powerline chips where the
|
||
terminal can draw them, and our own end stands on the app-wide ``★`` — every route on this
|
||
screen ends on us, so the star says it in one cell and leaves the rest of the lane to the
|
||
hops that differ from row to row (the same trade the trace route lane and the trophy card
|
||
make, and the same mark the graph above plants on our end). A hop nobody can name has
|
||
no name to show, so it stands in its own hash at the device's path-hash-mode width (the
|
||
width the radio itself carries per hop) in the app-wide unknown-node grey — colour being
|
||
the "this is a name" signal, exactly as in the graph above. No hop repeats its hash after
|
||
its name: the hash bytes are the *graph's* job one band up, and what ties a row to the fan
|
||
is the shared node hue rather than a second spelling of the hex.
|
||
|
||
Names cost cells that hashes don't, which is what the row's horizontal scroll is for (see
|
||
:meth:`NodeDetailScreen._route_row_lines`) — the same trade the Message paths rows make,
|
||
and the reason a name is worth it: a route reads as places, and the reader pays only for
|
||
the one row they are on. ``context`` leads with the route's hop count
|
||
(:func:`~meshterm.ui.pathline.hops_atom` — the figure the line above encodes but never
|
||
states, and the one two routes are compared on first), then the bottleneck SNR, sample
|
||
count, and a ``★ best`` / ``device route`` tag marking the winner and the firmware's
|
||
learned route — all kept off the pathline itself so a long chain never crowds it out.
|
||
The count means the row always earns a context line, which is right: every route has a
|
||
length, and a row whose second line came and went with the evidence made the list jump
|
||
a row taller as the cursor moved.
|
||
"""
|
||
hash_bytes = max(hash_bytes, 1) # an unknown mode still needs a real width to slice
|
||
|
||
def hop_of(hop: str) -> PathHop:
|
||
named = resolve(hop)
|
||
if named and named != hop:
|
||
return PathHop(named, key=hop)
|
||
return PathHop(_hop_hash(hop, hop, hash_bytes)) # unknown: its hash, keyless grey
|
||
|
||
path = PathLine(
|
||
[
|
||
PathHop(node_label, key=name_key)
|
||
if node_known and name_key
|
||
else PathHop(_hop_hash(name_key, node_label, hash_bytes)),
|
||
*(hop_of(hop) for hop in reversed(hops_out)),
|
||
PathHop(SELF_GLYPH, you=True),
|
||
]
|
||
).text()
|
||
|
||
atoms: list[Text] = [hops_atom(len(hops_out))]
|
||
if weakest is not None:
|
||
snr = Text("weakest ", style="muted")
|
||
snr.append(f"{weakest:+.1f} dB", style=snr_style(weakest))
|
||
atoms.append(snr)
|
||
if samples:
|
||
atoms.append(Text(f"{samples}×", style="muted"))
|
||
if tag == "best":
|
||
atoms.append(Text("★ best", style="brand"))
|
||
elif tag == "device":
|
||
atoms.append(Text("device route", style="accent"))
|
||
context = Text()
|
||
for i, atom in enumerate(atoms):
|
||
if i:
|
||
context.append(" · ", style="muted")
|
||
context.append_text(atom)
|
||
return path, context
|
||
|
||
|
||
def _cluster_presentation(members: tuple[str, ...], type_of) -> _Cluster: # noqa: ANN001
|
||
"""The marker + label a contracted bidirectional cluster draws under.
|
||
|
||
A homogeneous cluster (every member the same node type) wears that type's map marker and
|
||
hue — ``3 repeaters`` under a ``▲`` — so it reads as a group of that kind at a glance; a
|
||
mixed one falls back to the plain node dot and ``n nodes``.
|
||
"""
|
||
types = {type_of(m) for m in members}
|
||
only = next(iter(types)) if len(types) == 1 else None
|
||
if only is not None:
|
||
glyph, color = _NODE_GLYPHS.get(only, _DEFAULT_GLYPH)
|
||
kind = NODE_TYPE_LABELS.get(only, "node")
|
||
else:
|
||
glyph, color = _DEFAULT_GLYPH
|
||
kind = "node"
|
||
return _Cluster(glyph=glyph, color=color, label=f"{len(members)} {kind}s", rgb=mark_rgb(color))
|
||
|
||
|
||
def _contract_bidir_clusters(
|
||
routes: list[_Route], type_of
|
||
) -> tuple[list[_Route], dict[str, _Cluster]]: # noqa: ANN001
|
||
"""Fold each 3+ bidirectional cluster in the routes' draw sequences to one super-node.
|
||
|
||
A dense knot of mutually-relaying repeaters is a strongly-connected component the flow
|
||
graph can't order (see :func:`~meshterm.ui.pathgraph.bidir_clusters`); left as-is every
|
||
member collapses onto one jammed column. So each such cluster is contracted to a single
|
||
synthetic node: every route's ``draw`` has its members rewritten to the one cluster id
|
||
(consecutive members merged), and the returned map gives each cluster its marker and label.
|
||
|
||
Only ``draw`` — the graph geometry — changes. Each route's spelled-out ``row`` and its
|
||
trace ``spec`` keep every member named in order, so the list under the graph still carries
|
||
the exact ordering the single marker can't, and a trace still arms on the real path.
|
||
|
||
Returns ``(routes, clusters)`` unchanged (and an empty map) when there is no such knot.
|
||
"""
|
||
groups = bidir_clusters([(SRC_NODE, *route.draw, DST_NODE) for route in routes])
|
||
if not groups:
|
||
return routes, {}
|
||
member_of: dict[str, str] = {}
|
||
clusters: dict[str, _Cluster] = {}
|
||
for i, members in enumerate(groups):
|
||
cid = f"{_CLUSTER_NODE}{i}"
|
||
clusters[cid] = _cluster_presentation(members, type_of)
|
||
for member in members:
|
||
member_of[member] = cid
|
||
contracted: list[_Route] = []
|
||
for route in routes:
|
||
draw: list[str] = []
|
||
for hop in route.draw:
|
||
cid = member_of.get(hop, hop)
|
||
if draw and draw[-1] == cid:
|
||
continue # a run of members through the same cluster is one stop
|
||
draw.append(cid)
|
||
contracted.append(replace(route, draw=tuple(draw)))
|
||
return contracted, clusters
|
||
|
||
|
||
def _routes_view(
|
||
topo,
|
||
scenarios,
|
||
suggested,
|
||
device_route,
|
||
canonical_target,
|
||
target_hash,
|
||
width_bytes,
|
||
*,
|
||
resolve,
|
||
type_of,
|
||
key_of,
|
||
style,
|
||
self_name,
|
||
node_label,
|
||
name_key,
|
||
node_known,
|
||
hash_bytes,
|
||
) -> _RoutesView:
|
||
"""Build the Routes tab's selectable routes (node → us) + graph callbacks, or a muted note.
|
||
|
||
The routes are drawn contact-on-the-left to us-on-the-right — the *inbound* direction the
|
||
packets travelled to reach us, since everything the graph knows was received, not sent.
|
||
That is exactly the orientation the shared :func:`~meshterm.ui.widgets.route_graph_style`
|
||
already draws (it was built for the Message paths view, where traffic arrives *at* us on
|
||
the right), so we hand it the target as its ``source`` — the left endpoint — and use its
|
||
callbacks as they come, only reversing each route's hop order so the drawn line runs from
|
||
the contact inward to us.
|
||
|
||
The selectable list, strongest first, folds the old ``route`` / ``suggest`` info rows into
|
||
the tab: the best-evidence route (the observed suggestion, else the firmware's learned
|
||
route, else a bare direct shot), then the firmware's learned route when it is something
|
||
different, then the *good* observed alternatives. Only good alternatives are kept: an
|
||
observed route survives when its evidence is both **fresh** (its stalest hop heard within
|
||
:data:`_PATH_STALE_DAYS`) and **not an outlier** (its score within
|
||
:data:`_PATH_OUTLIER_RATIO` of the strongest observed one) — so the list is the routes
|
||
worth trusting rather than every chain ever heard. With no route evidence at all — no
|
||
learned route, no observed path, not even a direct link — there is nothing honest to draw,
|
||
so a muted note stands in and the tab still offers an auto trace.
|
||
|
||
Which route lights white (and which nodes keep their name hue) is the screen's live
|
||
selection, applied per render; this only assembles the routes and the base callbacks.
|
||
"""
|
||
from ..services.topology import render_forced_spec
|
||
|
||
if canonical_target is None:
|
||
return _RoutesView(note="no key to route to")
|
||
has_evidence = (
|
||
device_route is not None
|
||
or any(s.source == "observed" for s in scenarios)
|
||
or topo.link(topo.self_id, canonical_target) is not None
|
||
)
|
||
if not has_evidence:
|
||
return _RoutesView(note="no route observed yet — trace to discover one")
|
||
|
||
best_hops = (
|
||
suggested.hops
|
||
if suggested is not None
|
||
else device_route
|
||
if device_route is not None
|
||
else None
|
||
)
|
||
# A node we only ever hear directly (no relays, no learned route) still earns a line —
|
||
# the straight zero-hop shot — so the graph shows the direct link rather than a bare note.
|
||
if best_hops is None and topo.link(topo.self_id, canonical_target) is not None:
|
||
best_hops = ()
|
||
|
||
# The ordered, de-duplicated selectable routes (outbound hops us → target).
|
||
entries: list[tuple[tuple[str, ...], str, float | None, int]] = []
|
||
seen: set[tuple[str, ...]] = set()
|
||
|
||
def add(hops_out: tuple[str, ...], tag: str, weakest: float | None, samples: int) -> None:
|
||
key = tuple(hops_out)
|
||
if key in seen:
|
||
return
|
||
seen.add(key)
|
||
entries.append((key, tag, weakest, samples))
|
||
|
||
if best_hops is not None:
|
||
if suggested is not None:
|
||
add(best_hops, "best", suggested.weakest_snr, suggested.samples)
|
||
else:
|
||
add(best_hops, "best", None, 0)
|
||
if device_route is not None:
|
||
add(device_route, "device", None, 0)
|
||
for scenario in _good_alternatives(topo, scenarios, canonical_target):
|
||
add(scenario.hops, "", scenario.weakest_snr, scenario.samples)
|
||
|
||
routes: list[_Route] = []
|
||
for hops_out, tag, weakest, samples in entries:
|
||
spec = render_forced_spec(hops_out, target_hash, width_bytes) if target_hash else ""
|
||
path, context = _route_line(
|
||
node_label,
|
||
name_key,
|
||
hops_out,
|
||
tag,
|
||
weakest,
|
||
samples,
|
||
resolve=resolve,
|
||
node_known=node_known,
|
||
self_name=self_name,
|
||
hash_bytes=hash_bytes,
|
||
)
|
||
routes.append(_Route(draw=tuple(reversed(hops_out)), spec=spec, path=path, context=context))
|
||
|
||
# The legend explains the typed relay marks, so it is decided on the members' real types —
|
||
# before contraction folds a dense cluster's members behind one synthetic id.
|
||
legend = any(type_of(h) is not None for route in routes for h in route.draw)
|
||
# Fold any 3+ bidirectional cluster (a knot the flow can't order) to one super-node, so it
|
||
# draws as a single marker rather than piling its members onto one column. Each route's row
|
||
# and spec keep the members named in order — only the drawn geometry contracts.
|
||
routes, clusters = _contract_bidir_clusters(routes, type_of)
|
||
|
||
glyph_of, byte_label_of, label_rgb_of = style(
|
||
resolve=resolve, self_name=self_name, source=node_label, type_of=type_of, key_of=key_of
|
||
)
|
||
|
||
# The graph labels exactly as the Message paths graph does (JP, 2026-08-09): the two
|
||
# endpoints by name, every relay by its first hash byte alone. Names in here were the
|
||
# obvious read — until a busy fan drew them: each label is set above or below its own
|
||
# marker, and a long name spills across the lanes either side of it. Two cells never can,
|
||
# so the fan stays legible however many routes it carries. The row list under it is where
|
||
# the names live now, and a relay's byte and its row chip cross-reference by hue rather
|
||
# than by spelling the same node twice. A contracted cluster keeps its own count-and-type
|
||
# label — it stands for no single hash.
|
||
def label_of(node: str) -> str | None:
|
||
cluster = clusters.get(node)
|
||
return cluster.label if cluster is not None else byte_label_of(node)
|
||
|
||
base_rgb_of = label_rgb_of
|
||
|
||
def cluster_label_rgb_of(node: str) -> RGB:
|
||
cluster = clusters.get(node)
|
||
return cluster.rgb if cluster is not None else base_rgb_of(node)
|
||
|
||
# The target wears its own map glyph (▲ repeater, ■ room, ◉ sensor) — the same mark the
|
||
# header and the map give it. route_graph_style draws the far (left) endpoint as a plain
|
||
# dot, since on its home screen (Message paths) that end is an arbitrary message origin;
|
||
# here it is a known contact whose type we can show. A contracted cluster draws its own
|
||
# type mark; everything else keeps the style's glyph.
|
||
base_glyph_of = glyph_of
|
||
|
||
def cluster_glyph_of(node: str) -> tuple[str, str]:
|
||
cluster = clusters.get(node)
|
||
return (cluster.glyph, cluster.color) if cluster is not None else base_glyph_of(node)
|
||
|
||
glyph_of = _with_target_glyph(
|
||
cluster_glyph_of, _NODE_GLYPHS.get(type_of(canonical_target), _DEFAULT_GLYPH)
|
||
)
|
||
return _RoutesView(
|
||
routes=routes,
|
||
glyph_of=glyph_of,
|
||
label_of=label_of,
|
||
label_rgb_of=cluster_label_rgb_of,
|
||
legend=legend,
|
||
)
|
||
|
||
|
||
def _good_alternatives(topo, scenarios, target) -> list: # noqa: ANN001
|
||
"""The observed alternative routes worth drawing: fresh, evidence-backed, not outliers.
|
||
|
||
Trims the raw scenario list down to the alternatives the graph should show as grey
|
||
alternatives:
|
||
|
||
* only the **observed** family (the device/direct scenarios are not routes the evidence
|
||
*observed* the node arrive over — the device route rides on its own row, and a bare
|
||
direct line the evidence never saw is not worth a lane);
|
||
* only ones with real evidence behind them (a positive score — an unobserved link scores
|
||
zero, see :meth:`~meshterm.services.topology.MeshTopology._score_route`);
|
||
* only **fresh** ones — every hop heard within :data:`_PATH_STALE_DAYS`, so a route whose
|
||
weakest link has gone quiet drops out rather than lingering as a line that may no longer
|
||
carry;
|
||
* only ones **not far weaker** than the best observed alternative (score within
|
||
:data:`_PATH_OUTLIER_RATIO` of the strongest), so one clearly-best route isn't buried
|
||
under a fan of marginal ones.
|
||
|
||
Returns the survivors in the order :meth:`~meshterm.services.topology.MeshTopology.scenarios`
|
||
ranked them (strongest first).
|
||
"""
|
||
observed = [s for s in scenarios if s.source == "observed" and s.hops and s.score > 0]
|
||
if not observed:
|
||
return []
|
||
best_score = max(s.score for s in observed)
|
||
now = utcnow()
|
||
return [
|
||
s
|
||
for s in observed
|
||
if s.score >= best_score * _PATH_OUTLIER_RATIO
|
||
and _route_is_fresh(topo, s.hops, target, now)
|
||
]
|
||
|
||
|
||
def _route_is_fresh(topo, hops: tuple[str, ...], target: str, now: datetime) -> bool:
|
||
"""Whether every link along ``us → hops… → target`` was heard within the stale horizon.
|
||
|
||
A route is only as current as its stalest hop: if any link on it has not been heard in
|
||
:data:`_PATH_STALE_DAYS`, the chain may no longer carry, so the whole route counts as
|
||
stale. A link with no timestamp (evidence that carries no ``when``, e.g. a firmware route)
|
||
is treated as fresh — we have no age to hold against it.
|
||
"""
|
||
chain = [topo.self_id, *hops, target]
|
||
for a, b in pairwise(chain):
|
||
link = topo.link(a, b)
|
||
if link is None:
|
||
return False
|
||
last = link.last_seen
|
||
if last is None or getattr(last, "tzinfo", None) is None:
|
||
continue # undated evidence — no age to judge it stale by
|
||
if (now - last).total_seconds() > _PATH_STALE_DAYS * 86400.0:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _with_target_glyph(glyph_of: GlyphOf, target_glyph: tuple[str, str]) -> GlyphOf:
|
||
"""Wrap the graph's glyph callback so the target endpoint draws its own node glyph.
|
||
|
||
The graph's left endpoint (``SRC_NODE``) is the node this page is about, and here we know
|
||
its type — so it draws the map's own mark for that type (``▲`` repeater, ``■`` room,
|
||
``◉`` sensor, ``●`` plain), matching the identity header and the location preview, rather
|
||
than ``route_graph_style``'s generic origin dot. Every other node passes through
|
||
untouched (our own star on the right endpoint keeps the style's ``you`` mark).
|
||
"""
|
||
|
||
def wrapped(node: str) -> tuple[str, str]:
|
||
return target_glyph if node == SRC_NODE else glyph_of(node)
|
||
|
||
return wrapped
|