Files
MeshTerm/meshterm/ui/node_detail_screen.py
Jean-Pierre MartineauandClaude Haiku 5.5 59273a6b29 A locked author's sender chip carries the padlock, as ours carries the star
In a channel, a DM, or a room, the chip of a sender that is a locked
contact reads 🔒 Name (⚿ on the PicoCalc), in the place of the ★ on our
own chip. The transcript cache now compares the locks, so a ^K or a lock
from the node page redraws the chips on the next paint.

The padlock is one constant, marks.LOCK_ICON, shared with the Contacts list.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 23:40:32 -04:00

2557 lines
125 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""The Node detail screen: all the information about one node on one page, and the ways into it.
The user opens it with Enter on a contact in the Contacts list (refer to
:mod:`~meshterm.ui.contacts_screen`). The list has one aligned row for each node. This
screen is the node itself, in full:
* **Who it is.** A one-line identity header: the type glyph, the name in the hue of the
node, and the type label. It is the only chrome pinned above the stage, so the stage
keeps almost the full screen.
* **A stage with tabs** below the header. It shows one full-height tab at a time. The
user changes the tab with ``Tab``/``Shift+Tab`` on a strip of tabs in a box (refer to
:func:`~meshterm.ui.widgets.tab_strip`). The vitals, the location preview, and the
route graph are not stacked in one long scroll. Instead, each tab gets the full stage:
* **Info**: the vitals of the node as labelled rows. These are its key with the routing
hash lit, when it was first and last heard, how many of its packets the device
overheard, its reception SNR and last RSSI, and where it is. For a repeater, they are
also the regions for which it was last heard to relay floods, whether it also relays
unscoped floods, and when it said so (refer to :func:`regions_value`). The key is on
one lane, and it is never wrapped. A full public key is longer than the row on each
terminal that MeshTerm supports. Thus ``←→`` scroll it by four bytes at a time, under
faint ``…`` marks at the edges, the same as a long path line scrolls on the Routes
tab. Then, when the node has advertised a location, a static basemap preview (refer
to :class:`~meshterm.ui.minimap.MiniMap`) centred on the node. The preview grows into
the rows of the viewport that remain. A short frame (the Cardputer) does not show it.
The actions of the tab: ``Open full map`` opens the full map, centred here, with its
find filter set to this node, so the node is lit among the others. Then
``Time machine``. ``Share contact`` opens a contact card in a dialog (a QR code and a
``meshcore://`` link, refer to :func:`~meshterm.ui.config_editor.show_contact_card`).
It is available when the full key of the node is known.
``Ask which regions it carries`` is on a repeater. It sends one anonymous request,
and the answer comes only when it arrives direct. MeshTerm learns the answer into the
region store, and the row is drawn again in place.
Last, the contact-management group, when the caller lets the page manage the contact
(refer to :func:`open_node_detail`). It has ``Lock contact`` or ``Unlock contact``,
then ``Archive contact`` or ``Restore to device`` when they apply, then
``Delete contact…``. ``Delete contact…`` is the last row, because it is the only
action that cannot be undone. It is the single-contact counterpart of the bulk sweep
of the Contacts list (refer to :mod:`~meshterm.ui.contacts_screen`), but it deletes
where the sweep archives. After a red confirm, it removes this node from the contact
table of the device and from the contacts that MeshTerm remembers for the device.
Then the page closes, because the contact that the page shows does not exist any
more. An archive and a restore also close the page. A lock does not. A page that a
conversation opens has only the lock row of this group.
* **Routes**: the routes on which the device really heard the node arrive. The shared
route graph (:mod:`~meshterm.ui.pathgraph`) draws them from the node to our node, in
the inbound direction of the packets: the contact on the left, our node on the right.
The label of each relay is only the first byte of its hash. That is two cells, so the
graph stays readable with any number of graph lanes.
Below the graph is the *route list*, which has the names. It has one selectable row
for each different route: the route that the firmware learned, and the observed
alternatives, with ``★ best`` on the strongest. Each row is one **named** line, drawn
by the only path widget. A hop that has no known name shows its hash instead. The
line below it has the bottleneck SNR, the sample count, and the tag. The two parts
refer to each other by node hue, and the same hex is not written two times. The
Message paths dialog has exactly the same split.
``↑↓`` moves the selection. The selected route is white in the graph, and the other
routes are grey. Each node that is not on the selected route has a grey label. Thus
the graph shows this one route among all the routes. A path line that is too wide
for the lane never wraps. Instead, the highlighted row scrolls horizontally (``←→``)
under faint ``…`` marks at the edges, so that the user can read it to the end. A
long row that is not selected gets an ellipsis.
Only good routes are drawn. MeshTerm removes stale evidence and outliers that are
much weaker. Thus the list has the routes that the user can trust, not each chain
that was ever heard. Enter on a route prepares a trace on it. (Nothing transmits
here. Enter opens the trace screen with that path.) When there is no route evidence
to list, the ``🎯 Trace — auto route …`` action is shown instead.
* **The ways in.** The action rows of each tab (``↑↓`` moves the highlight through them,
and Enter commits). There is no Back row: Esc on each tab closes the page and goes back
to the list.
The page never scrolls as one long strip. The identity header, the tab strip, and the
stage are pinned. The stage gets its size from the terminal: the route graph compresses
its graph lanes before it overflows, and the location preview grows into the rows that the
Info tab leaves. A faint rule closes the stage, so the drawn tab and the rows below read as
separate bands. The route list scrolls in the remaining rows, with faint ``↑ n more`` and
``↓ n more`` markers at its edges. This is the list window of the whole app (refer to
:class:`~meshterm.ui.tui.screen.ListWindow`, which fits whole blocks). Thus the graph,
the selection that controls it, and the action rows share one screen, also when a busy
node has many routes. ``PgUp/PgDn`` move the highlight through the list window by pages.
The screen only shows data and returns a choice. It renders display data that is already
resolved, and it resolves with an action token. (The Trace token carries the spec of the
selected route, through :meth:`NodeDetailScreen.selected_spec`.) :func:`open_node_detail`
gets the data and runs the sub-flows that each action opens, then shows the page again.
The Time Machine and the Contacts list use the same loop. The screen itself never
transmits. The only action that transmits is the regions question. The opener asks it one
time, when the user commits its row.
"""
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, DeviceCommandError
from ..core.geo import haversine_km, usable_fix
from ..core.models import NODE_TYPE_LABELS, NODE_TYPE_REPEATER, Contact, utcnow
from ..platforms import Platform, on_platform
from . import menus
from .mapcanvas import RGB
from .marks import SELF_MARK
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,
_recency_style,
age_seconds,
format_ago,
highlighted_hash,
node_type_legend,
short_frame,
tab_air,
tab_strip,
)
if TYPE_CHECKING: # pragma: no cover - typing only
from ..context import AppContext
#: Whether the route graph has a caption ("node → you, as heard · white = selected route"),
#: and whether it can keep the empty canvas row that its top space leaves. The PicoCalc has
#: neither (JP, 2026-08-09), and both rows go to the graph instead. The caption explains a
#: picture that the white line and the ``you`` star already make clear. The end padding of
#: the renderer (:data:`~meshterm.ui.pathgraph._GRAPH_END_DOTS`, two cells) keeps a full
#: cell of empty space above the label of the top graph lane, and nothing ever draws into it.
#: This value follows the same signal for economy of rows as the space of the tab strip
#: (:func:`~meshterm.ui.widgets.tab_air`). It is bound at platform-switch time, like each
#: platform constant.
_GRAPH_CAPTION = True
@on_platform
def _bind_graph_caption(platform: Platform) -> None:
"""Bind the caption and the top space of the graph to the platform (now and at each switch)."""
global _GRAPH_CAPTION
_GRAPH_CAPTION = platform.frame_border
#: An SGR escape, so that MeshTerm can check whether a rendered canvas row holds only
#: empty space.
_SGR = re.compile(r"\x1b\[[0-9;]*m")
#: The row limits of the inline location preview. It grows into the rows of the viewport
#: that the Info tab leaves (the vitals and the action rows are short). The minimum lets a small
#: terminal still show an area that the user can recognize. The maximum keeps the map from
#: filling all of a tall terminal.
_MAP_MIN_ROWS = 5
_MAP_MAX_ROWS = 13
#: The maximum number of rows of the route graph on the Routes tab. The graph draws the
#: contact on the left and our node on the right (node → us). Thus the graph reads from left
#: to right in the inbound direction in which its packets travelled to our node. A node with
#: many connections has several candidate paths at the same time. If MeshTerm puts them into
#: a short box, the graph lanes are compressed together. Thus this page keeps the shared
#: pitch of the graph lanes, and lets the graph grow with its graph lanes, up to this limit.
#: The real budget is calculated for each render against the terminal. The stage gets the
#: rows of the viewport that remain after the pinned chrome, the action rows, and the
#: minimum list window of the route list. Thus the graph compresses its pitch before it can
#: push the list or the actions off the screen, and it gets to this limit only on a
#: terminal that is tall enough.
_PATH_MAX_ROWS = 22
#: The minimum number of canvas rows for the route graph (the minimum of the shared
#: renderer). On a terminal that is too short for this minimum, the frame follows the
#: highlight and keeps the active row visible. The stage does not become so small that it
#: shows nothing useful.
_GRAPH_MIN_ROWS = 5
#: The number of route-list lines that the stage must leave free before it takes the
#: remaining rows of the viewport. That is sufficient for two routes (or one wrapped route)
#: to show next to the graph that shows them in white.
_LIST_MIN_LINES = 4
#: MeshTerm removes a drawn route as stale when its limiting link (the stalest hop that it
#: goes through) was not heard in this number of days. A route is only as current as its
#: link that was heard least recently. If a hop on it has gone quiet, the full chain may not
#: carry packets now. This value agrees with the one-week evidence half-life of the topology
#: (after four half-lives, a link has a weight of approximately 1/16). After that point, a
#: path is more a memory than a fact.
_PATH_STALE_DAYS = 28.0
#: MeshTerm removes a drawn alternative route as an outlier when its evidence score is less
#: than this fraction of the score of the strongest observed alternative. Thus the graph
#: keeps the few routes that the user can trust, not each much weaker chain that the
#: evidence can connect. The route with the best evidence (white) is always drawn, because
#: it is the answer to "how do we reach it", not one of the alternatives that MeshTerm
#: compares.
_PATH_OUTLIER_RATIO = 0.25
#: The cells that the labelled info rows keep for their label lane. Thus the value blocks
#: align, and a wrapped value hangs under itself, not under the label (the hanging-indent
#: rule of the whole app). The size is the widest label of the block ("packets").
_LABEL_LANE = 9
#: The edge colour of the selected route (white, the spine) over the grey of the
#: alternatives, and the grey of the label of a node that is on no part of the selected
#: route. One grey is used for both. Thus the line of a relay that is not on the route, and
#: its name, show the same dim "not this route".
_WHITE = (255, 255, 255)
_GREY = (120, 120, 120)
#: The synthetic node-id prefix under which a contracted bidirectional cluster is drawn. A
#: ``\x00`` at the start makes it not hex, so the graph never tries to merge it with a hash,
#: and it cannot be equal to a real hop. The graph uses its own endpoint sentinels in the
#: same way.
_CLUSTER_NODE = "\x00clu"
#: The indent in cells of the path line of a route row, and of the context line that hangs
#: under it. It is the width of the ``"❯ "`` or ``" "`` pointer, so the context aligns under
#: the path that it is part of.
_ROUTE_INDENT = 2
#: The number of cells by which one ← or → key press scrolls the path line of the
#: highlighted route, or the key lane of the Info tab (refer to
#: :meth:`NodeDetailScreen.handle`). It is the same step that the ``hscroll`` of the select
#: list uses everywhere in the app. On a key, it is exactly four bytes. Thus each position at
#: which the lane can stop starts on a byte boundary, in the same way that a hash reads.
_HSCROLL_STEP = 8
#: The info row whose value is a key: the only lane that scrolls (``←→``) and does not wrap.
#: A public key has 64 hex digits. That is wider than the value lane on each platform, and it
#: has no word break. Thus a wrap only adds a second line that says nothing. All the other
#: vitals here are prose that is short enough to hang under itself.
_KEY_LABEL = "key"
#: The faint mark at each edge of the key lane past which the key continues.
_MORE_MARK = "…"
#: The highlight moves that cancel the horizontal scroll of a route. Each row scrolls
#: independently. Thus, when the highlight leaves a row, the shift goes back to zero, and it
#: does not go to the next row.
_HSHIFT_RESET_ACTIONS = frozenset(
{
"up",
"down",
"pageup",
"pagedown",
"home",
"ctrl_home",
"end",
"ctrl_end",
}
)
@dataclass(slots=True)
class _Action:
"""One action row at the bottom of a tab.
Attributes:
key: The token that the screen resolves with when the user commits this row.
glyph: The icon at the start of the row.
glyph_style: The style of the icon.
label: The text of the row (with a current value in it, muted where it is
context).
"""
key: str
glyph: str
glyph_style: str
label: str
@dataclass(slots=True)
class _Route:
"""One selectable route on the Routes tab: how it is drawn, traced, and read.
Attributes:
draw: The relay hops in the inbound draw order of the graph (contact → us). Thus a
:class:`~meshterm.ui.pathgraph.PathLayer` built from them puts the contact on
the left end and our star on the right end. Empty means a direct route with
zero hops.
spec: The forced-path spec that a trace prepares when the user selects this route
(the symmetric round trip through these hops). ``""`` lets the trace screen
resolve the path automatically.
path: The path line, rendered in advance. It is the full route, named by the only
path widget (contact → relays → us). The mark at the end that says that the row
opens more prompts is added at render time
(:func:`~meshterm.ui.pathline.with_action_mark`), and it is not stored here. It
is one line, and it is never wrapped. Instead, the highlighted row scrolls
horizontally, so that the user can read a long hash chain past the width of the
lane. An unselected row gets an ellipsis.
context: The line that hangs under ``path``: the hop count, then the bottleneck
SNR, the sample count, and a ``★ best`` or ``device route`` tag when the route
has them. The hop count is always there, so this line is never empty.
"""
draw: tuple[str, ...]
spec: str
path: Text
context: Text
@dataclass(slots=True)
class _Cluster:
"""The marker on the graph that stands for a contracted bidirectional cluster.
Three or more repeaters that relay each other in each order make a knot that the flow
from left to right cannot place (refer to :func:`~meshterm.ui.pathgraph.bidir_clusters`).
Instead, they are drawn as one super-node, and this class is how that super-node looks.
The route rows below the graph still name each member in order. Thus the user finds the
detail that the marker hides immediately below the graph.
Attributes:
glyph: The glyph of the marker: the node-type glyph that all the members share
(``▲`` repeater, ``■`` room, …), or the plain node dot if they do not agree.
color: The ``#rrggbb`` colour of the marker glyph (the colour of the node type, or
the colour of the plain dot).
label: The label with the count and the type, for example ``3 repeaters``
(``n nodes`` when the types are mixed).
rgb: The colour of the label: the same type colour as the marker. Thus the
super-node reads as a group of one type, not as a named node.
"""
glyph: str
color: str
label: str
rgb: tuple[int, int, int]
@dataclass(slots=True)
class _RoutesView:
"""The selectable routes of the Routes tab and the shared draw callbacks, or a note.
The routes are drawn as one graph (the spine with the best evidence, and the
alternatives). The live selection of the screen sets which route is white, and which
nodes keep the hue of their name and do not become grey. Thus the layers and the label
colours are composed for each render, and they are not stored here.
Attributes:
routes: The selectable routes (strongest first), or empty when there is no route
evidence. Then ``note`` has the muted text that is shown instead.
glyph_of: The marker callback of the graph, for each node.
label_of: The label callback of the graph, for each node.
label_rgb_of: The label-colour callback, for each node (the base colour, not
muted. The screen makes the nodes off the route grey over it).
legend: Whether to draw the node-type legend below the graph (a relay with a known
type was shown).
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 strip of the stage.
Attributes:
name: The label on the strip (``Info`` or ``Routes``).
kind: The stage that the tab draws (``info`` or ``routes``).
"""
name: str
kind: str
class NodeDetailScreen(Screen):
"""A full-screen page for one node: the identity, a stage with tabs, and the ways in.
The screen only shows data and returns a choice. It renders display data that is
already resolved (refer to :func:`open_node_detail`, which assembles it). On Enter, it
resolves with the token of the highlighted action, and the opener does the action (a
Trace token goes with :meth:`selected_spec`). Esc resolves :data:`CANCEL` to leave.
There are two axes of navigation, as in the spatial controls of the whole app.
``Tab``/``Shift+Tab`` changes the tab that fills the stage. ``↑↓`` moves the highlight
in the active tab, through its route list and its action rows. (On the Routes tab, the
selection controls the white route of the graph, and Enter prepares a trace on the
selected route.) The page itself never scrolls. The identity header, the tab strip, and
the stage are pinned, the stage gets its size from the viewport, and the route list
puts itself in a list window in the remaining rows. ``PgUp/PgDn`` move the highlight through
the list by pages, and ``Home/End`` move it to the ends.
``←→`` scroll the one item of the active tab that is wider than its lane, one lane for
each tab: the path line of the highlighted route on Routes, and the key on Info. Both
keys work only when the item really overflows. Thus, when there is nothing to scroll,
the keys have no effect, and the hint does not show them.
"""
floating = False
@property
def picocalc_lyra_lane(self):
"""The shared pager over the route list, and the tab switch on F3.
The strip shows that there are two tabs. But nothing on the screen gives the key
that moves between them, and on a platform with no hint line, the chip is the only
place to learn it. The chip names the tab that it goes to, never the tab that is
open. The strip already marks the open tab, and a chip that repeats it says nothing
about what a press of the chip does. (We tried a chip for each tab on F1/F2, then
removed it. JP, 2026-08-09: the toggle was better.) Like each cycling chip, it is
only the name of the target, with nothing before it. ``Routes`` is exactly six
cells, and the strip next to it makes the direction clear. A page with one tab has
nothing to switch, so the slot stays empty.
The pager works only when the route list is really in a list window. 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
@property
def title(self) -> str: # type: ignore[override]
"""``Node — <name>``, or on a short frame the identity mark, ``▲ <name>``.
On a short frame (the 14 rows of the Cardputer, JP 2026-10-04), the title bar is the
identity. The type glyph is shown instead of the word ``Node``, and the name has its
own hue (:meth:`bar_title`). Thus the identity line under the bar only says the same
thing two times, and its row goes to the stage instead (refer to
:meth:`render_body`). The frame reads this value after the paint that set it,
because the frame draws the bar after the body.
"""
return self._mark.plain if self._compact and self._mark is not None else self._title
@title.setter
def title(self, value: str) -> None:
self._title = value
def bar_title(self, title: str, style: str) -> Text:
"""In the bar, the identity mark keeps the type colour of its glyph and the name hue."""
if self._mark is not None and title == self._mark.plain:
return self._mark.copy()
return super().bar_title(title, style)
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,
mark: Text | None = None,
) -> None:
"""Build the page for display data that is already resolved.
Args:
title: The heading of the screen (``Node — <name>``).
header: The identity line: the type glyph, the coloured name, and its type
label.
mark: The identity as the title bar shows it on a short frame: the type glyph
in its type colour and the name in its own hue, ``▲ Hilltop-Repeater``
(refer to :attr:`title`). ``None`` keeps the plain title and the identity
line there.
info_rows: ``(label, value)`` pairs for the block of vitals on the Info tab.
Each pair renders as a muted label lane, with the value hanging under itself
when it wraps.
tabs: The stage tabs to show, in the order of the strip (empty means no stage,
only actions).
minimap: The inline location preview of the Info tab, or ``None`` (no
advertised fix).
map_caption: A faint line under the preview (its centre and scale), when a map
shows.
routes: The route list and the graph callbacks of the Routes tab (or a muted
note), or ``None`` when there is no Routes tab (our node never overhears
itself).
info_actions: The action rows of the Info tab (Open full map when there is a
fix, Time machine when the recorder has history, Share contact when the
full key is known).
trace_action: The ``Trace — auto route …`` action of the Routes tab, or
``None``. It is shown only when there are no routes to list. When routes
are listed, Enter on a route row starts the trace.
"""
super().__init__()
self.title = title
self._mark = mark
#: Whether the last paint was on a short frame with a mark to show (refer to
#: :attr:`title`).
self._compact = False
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 width in cells of the icon lane of the action rows. It is measured one time
#: over each mark that this page can draw. Thus a one-cell ``🗑`` gets padding to the
#: width of its two-cell siblings, and each label starts in the same cell. It is zero
#: on a platform that draws no icon lane.
self._icon_lane = self._measure_icon_lane()
self._tab_index = 0
self._row_index = 0
#: The highlighted route on the Routes tab (it controls the graph). It follows the
#: highlight when the highlight moves onto a route row, and it stays when the
#: highlight is on an action row. Thus the graph continues to show the last selected
#: route.
self._route_sel = 0
#: The composed route graph for one (width, rows, selection). Refer to _routes_stage.
self._stage_memo: tuple[tuple, list[str]] | None = None
self._cursor: int | None = None
#: The list window of the route list over the remaining rows (its ``page`` is the
#: step of PgUp and PgDn), and whether the list window hides rows (the scroll atom of
#: the footer shows only then).
self._list = ListWindow()
self._list_hidden = False
#: The horizontal scroll of the path line of the highlighted route (the cells moved,
#: ``←/→``). It goes back to zero each time the highlight leaves that row (refer to
#: :data:`_HSHIFT_RESET_ACTIONS`).
self._hshift = 0
#: The horizontal scroll of the key lane of the Info tab (the cells moved, ``←/→``).
#: It is not part of the highlight, as the scroll of the route path line is. The key
#: is pinned chrome in the stage, not a row that the highlight can go to. Thus a move
#: through the action rows leaves it where the user put it. Only when the user leaves
#: the tab does it go back to zero.
self._key_shift = 0
#: The last render width. With it, the footer hint can tell whether the path line of
#: the highlighted route really overflows (nothing overflows 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, the item that ``←→`` scrolls here, Esc last.
When both apply, the scroll atom puts ``PgUp/PgDn`` and ``←→`` into one atom. (Both
apply when the list of a busy node is in a list window and its highlighted route also
overflows.) Two atoms can go past the hint budget of 72 cells. On the Info tab, the
``←→`` atom names its subject, because the key is the one thing there that scrolls,
and the highlight is at a different place. Each atom shows only when there is
something to scroll. Thus the line never shows a key that does 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 path line is too wide for the content lane of the list.
The ``←→`` scroll and its footer atom work only when this is true, because a path
line that fits has nowhere to scroll. The value is measured against the last render
width. That width is ``0`` before the first paint, so nothing overflows 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 `…` at the end is chrome outside the lane (pathline.with_action_mark), so it
# is not part of what must fit. A route exactly as wide as its lane does not scroll,
# and the mark is the thing that is not shown.
return cell_len(routes[self._row_index].path.plain) > avail
def _on_info_tab(self) -> bool:
"""Whether the Info tab fills the stage (thus its key lane is visible)."""
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 key value of the Info tab, or ``None``. It is the one vital in a lane that scrolls.
The method finds the row by its label, not by its position. The block is assembled
at a different place (:func:`open_node_detail`), and the rows around the key come
and go with what is known about the node. But the row with the label
: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. ``←→`` and the footer atom work only then.
The value is measured against the last render width, exactly as the sibling check
for the route path line is. That width is ``0`` before the first paint, so nothing
overflows until a real width is known.
"""
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 highlighted route (``""`` means automatic).
The opener reads this value after a ``trace`` token resolves. Thus a trace prepares
the route that the highlight selected, not always the best route.
"""
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 right edge of the panel only while the Info tab shows 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 _measure_icon_lane(self) -> int:
"""The icon lane of the action rows, measured over each mark that is now on the page."""
trace = [self._trace_action] if self._trace_action else []
return menus.icon_lane(action.glyph for action in (*self._info_actions, *trace))
def replace_info_row(self, label: str, value: Text) -> None:
"""Replace the value of one vital in place: the regions row, after the repeater answered.
The row keeps its place in the block (a label that is not there yet is added at the
end). The highlight stays on the row that it was on, because the page is the same
visit, with one newer fact.
"""
for i, (existing, _value) in enumerate(self._info_rows):
if existing == label:
self._info_rows[i] = (label, value)
return
self._info_rows.append((label, value))
def replace_info_actions(self, actions: list[_Action]) -> None:
"""Replace the action rows of the Info tab in place, with the highlight on the same row.
This is for an action that changes what the page offers, but does not end the
visit. For example, when the user locks a contact, its row becomes Unlock, and
Archive goes away. The highlight follows the committed row by its
:attr:`_Action.key` family (``lock`` and ``unlock`` are one row). Thus the next key
press of the user goes to the same row as the last.
"""
focus = self._focusables()
current = focus[self._row_index % len(focus)][1] if focus else None
was = getattr(current, "key", None)
self._info_actions = list(actions)
self._icon_lane = self._measure_icon_lane()
family = {"lock": "unlock", "unlock": "lock"}
for index, (_kind, payload) in enumerate(self._focusables()):
key = getattr(payload, "key", None)
if key is not None and key in (was, family.get(was or "")):
self._row_index = index
break
def handle(self, action: str, data: str = "") -> None:
"""Answer one key press on the page.
Change the tab, move the highlight in a tab, commit a row, page the list, scroll
the lane of the active tab that is too wide (the path line of the highlighted
route, or the key of the Info tab), or leave.
"""
focus = self._focusables()
n = len(focus)
if action in _HSHIFT_RESET_ACTIONS:
self._hshift = 0 # each row has its own scroll, lost when the highlight leaves
if action == "enter":
if n:
kind, payload = focus[self._row_index % n]
if kind == "path":
# A route row itself starts the trace. Enter prepares a trace on the route
# that the row names (the opener reads :meth:`selected_spec` to get it).
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. The highlight does not go around to the other end,
# because the route rows scroll in a list window (refer to ListWindow). If the
# highlight jumps from one end to the other, the list window goes with it.
# That is the only move that looks like a change of the whole screen.
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 that is too short for the pinned layout, the frame follows the
# highlight. Home must show the very top of the page, with the header.
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 path line end at render
elif self._key_overflows():
self._key_shift += _HSCROLL_STEP # clamped to the end of the key at render
elif action == "escape":
self.resolve(CANCEL)
def _on_route_row(self, focus: list[tuple[str, object]]) -> bool:
"""Whether the highlight is now on a route row (and not on an action row)."""
return bool(focus) and focus[self._row_index % len(focus)][0] == "path"
def cursor_line(self) -> int | None:
"""The body line of the highlighted row, which the frame keeps visible.
That has an effect only on a terminal that is too short for the minimums of the
pinned layout.
"""
return self._cursor
def _switch_tab(self, delta: int) -> None:
"""Change the active tab, and move the highlight and the list window to its 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:
"""Make the route under the highlight the white route of the graph, if it is 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 highlight stops: ``("path", route_idx)`` and ``("action", _Action)``.
These are the stops of the active tab. On the Routes tab, the route rows come first,
and each row itself starts the trace. The auto-route Trace action is there instead
only when there are no routes to list. The Info tab has its own actions (for example
Open full map and Time machine). No tab has a Back row, because Esc closes the page.
"""
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 stage sized to fit, and the rows in a list window.
At each paint, the viewport that the frame noted
(:meth:`~meshterm.ui.tui.screen.Screen.note_viewport`) is divided into three
parts. First, the pinned chrome (the identity header and the tab strip) and the
action rows get their fixed lines. Then the stage gets what it needs of the
remaining lines. (The row limit of the route graph becomes smaller to fit, and the
location preview of the Info tab grows into the rows that the vitals leave.) Last,
the route list puts itself in a list window in the remaining lines. Thus 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
short = short_frame(viewport)
# On a short frame, the title bar shows the identity (refer to :attr:`title`). Thus
# the identity line under the bar is removed, and so is the rule under the route
# graph. Both rows go to the graph, whose row limit comes from the remaining rows.
self._compact = short and self._mark is not None
# -- pinned chrome: the identity header, then the tab strip. (A single tab collapses
# to one line. A strip of more tabs puts the active tab in a box of three lines.)
# The budget below is calculated from ``len(lines)`` after this, so the size is
# correct for each shape.
lines: list[str] = []
if not self._compact:
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,
compact=short,
),
width,
no_wrap=True,
)
)
# The action rows are also fixed chrome: a blank line first, then one line for each
# row. They are subtracted before the stage draws, so that the stage can get its
# 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 the rest of the viewport, 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 line of the rule
lines.extend(self._info_stage(width, budget, short=short))
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 note alone pays for its own space under the strip, where the
# platform gives the strip space.
lines.extend([""] * tab_air())
rule = 0 if short else 1 # on a short frame, the graph keeps the rule line
budget = viewport - len(lines) - action_lines - rule
lines.extend(self._routes_stage(width, budget, route_blocks))
# The rule closes the stage, so the drawn tab and the rows below it read as
# separate bands, not as one continuous column. The Routes tab on a short frame
# has no rule (JP, 2026-10-04). The white line of the selected route and the
# highlight of the list already divide the two, and the row is more useful to
# the graph.
if tab.kind == "info" or not short:
lines.append(render_to_ansi(Text("─" * width, style="faint"), width))
# -- the route list, in a list window in the rows that 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 come before them in the 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, *, short: bool = False) -> list[str]:
"""The block of vitals and, with a fix, the location preview grown to fit.
``budget`` is the number of visible lines that remain for the full stage. The rows
of vitals are never truncated. The preview changes its size instead: it takes the
lines that the vitals and its caption leave, between ``_MAP_MIN_ROWS`` and
``_MAP_MAX_ROWS``. When the vitals leave less than the minimum, the preview keeps
its minimum and the page scrolls. On a ``short`` frame, the preview is not shown at
all, because its minimum alone pushes the header or the actions off the screen.
Each row, but not the key, hangs under itself when it wraps. The key keeps its one
line and scrolls (refer to :meth:`_key_line`). Thus the row count that sets the
size of the preview cannot change while the user scrolls through a long key.
"""
lines: list[str] = [""] * tab_air() # the space of the stage under the strip, if any
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,
)
)
# A short frame has no rows for the preview. The 14 rows of the Cardputer leave it
# two, and its minimum pushes the identity header and the actions off the screen.
if self._minimap is not None and not short:
caption = 1 if self._map_caption is not None else 0
room = budget - len(lines) - 1 - caption # minus the blank line above it
rows = max(_MAP_MIN_ROWS, min(_MAP_MAX_ROWS, room))
lines.append("")
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 lane that scrolls: the label, then the part that ``←→`` set.
A full public key has 64 hex digits. The lane is the width that the terminal leaves
after the label: 63 cells at the standard width of 72 cells, fewer on the PicoCalc.
Thus the key never fits. A wrap uses a full second line for the few digits that do
not fit. Instead, the lane shows a part of the key, and :attr:`_key_shift` moves
that part (``←→``, :data:`_HSCROLL_STEP` cells for each key press). A faint
:data:`_MORE_MARK` is at each edge past which the key continues. This mark tells the
user that the row is a viewport, not the full value.
The marks are next to the visible part, not over its first cell. Thus the digits
keep their byte alignment at each shift (the step is four full bytes). A key always
reads in byte pairs, and a part that starts in the middle of a byte shows it wrongly.
The shift is clamped here, against the width that is really drawn, to the first
step that brings the end of the key into the lane. Thus a scroll can never go into
an empty lane, and a resize can only pull a shift that is out of range 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)
# When the lane scrolls, it gives one cell to the left mark immediately. Thus its last
# visible part has ``avail - 1`` cells. The limit is the first full step that gets to
# 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, 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 row limit of the graph is what remains after its caption and legend, and after
the minimum of the list (the smaller of :data:`_LIST_MIN_LINES` and what the rows
really need). The limit is never less than :data:`_GRAPH_MIN_ROWS`. Thus the graph
compresses the pitch of its graph lanes before the list loses its list window, and
the graph of a busy node spreads out only on a terminal that is tall enough. As
always, the selected route is white, and the labels that are not on it are grey.
Where the caption is removed (:data:`_GRAPH_CAPTION`, on the PicoCalc), the graph
is drawn one row more than its limit, and then its first blank canvas row is
removed. The renderer always centres the band of graph lanes in two cells of end
padding, but its top label needs only one. Thus that row is always empty, and its
removal pays for the row that the graph got. Both rows go back into the limit
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 data is a snapshot (the opener built it one time). Thus the full graph
# (a graph layout in many passes) is a pure function of the width, the row budget,
# and the emphasized route. One slot is sufficient: the cache key changes only on a
# change of selection or a resize, and then the previous layout is not used again.
# The node-type legend is not shown on a short frame, the same as in the message
# paths dialog. The markers have one meaning in the whole app, and the rows go to
# the graph and the list.
legend = rv.legend and not short_frame(self._scroll_viewport)
caption_lines = (1 if _GRAPH_CAPTION else 0) + (1 if 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, legend)
if self._stage_memo is not None and self._stage_memo[0] == stage_key:
return self._stage_memo[1]
# The order of the evidence sets the priority (route 0, the route with the best
# evidence, is always the spine). Thus the shape of the graph never moves when the
# selection changes. Only the emphasis follows the selection: which route is white
# and on top, and which nodes keep their hue.
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 is on the selected route. All that is off the
# route (marker and label) becomes grey. This is now the rule of the widget itself
# (refer to :func:`~meshterm.ui.pathgraph.render_path_graph`), and the widget
# decides it in the id space of the graph. Thus a route that gets to a relay by a
# short hash still gives the hue to the wide marker into which its hop is folded.
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 legend:
lines.extend(render_lines(node_type_legend(width=width), 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 path line with the ``❯`` pointer, and its context on the line below.
The route keeps the colours of its nodes, also when it is selected. (The pointer and
the white line in the graph above show the selection.) The plain action rows are
different: they take the highlight. Here, the colour is the content. The ``…`` at the
end is the mark of the whole app that says that the row opens more prompts: Enter on
the row prepares a trace on it. The mark is outside the lane
(:func:`~meshterm.ui.pathline.with_action_mark`), so the chrome takes no cells from
the route. Thus a chain that fits stays whole and ends on its rounded cap. It does
not crack to make space for two cells of hint. Where the lane is full, the mark is
the thing that is not shown.
The path line never wraps at a hop. It is one line, cropped. The highlighted row
moves with :attr:`_hshift` (``←→``, refer to :meth:`handle`). Thus the user can read
a named chain that is wider than the lane to its end, under a
:func:`~meshterm.ui.pathline.cut_mark` at each edge past which the line continues.
That mark is a chip broken off in its own colour where the route is drawn in chips,
and the faint ``…`` where it is drawn in arrows. It is the same horizontal scroll as
in the key lane of the Info tab, and the same as in the rows of Message paths. That
scroll is what lets the row show names (:func:`_route_line`). A hop chain written as
names becomes longer than a lane much sooner than a chain written in hash bytes. The
solution is to read it, not to make it shorter.
All the other rows are cut to the lane (:func:`~meshterm.ui.pathline.cut_to`,
cracked or with an ellipsis by the same rule). The context line under each row
(plain prose, never a path) gets an ellipsis. Only the highlighted row can scroll,
and it is the only row whose end the user asks to see. The context (hop count,
bottleneck SNR, sample count, the ``★ best`` or ``device route`` tag) always starts
with the count. Thus each row is two lines tall, and the height of the list does not
change while the highlight moves through 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 gave one cell to its left mark, so its last visible part has
# ``avail - 1`` cells. Clamp to the first full step that brings the end into that
# part. If the clamp is less than that step, the right mark is still drawn at
# the far end, and it shows more text that the keys cannot get to. (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 in the visible part, not more width. Each mark takes a cell of
# the lane, so the crop is measured after the marks are known. If not, the line
# drawn under a right ``…`` goes one 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: ``❯``, the icon, and the label, all white when it is highlighted."""
text = Text("❯ " if selected else " ", style="cursor" if selected else "")
# The icon lane is decoration, and a platform can remove all of it (command_icon).
# The label names the action, so an empty lane gives its cells to the label. But a
# tinted icon has a meaning that does not go away with the decoration: a red 🗑 is
# how a destructive row shows what it is. Thus, where the lane is removed, the label
# gets the tint instead. :func:`~meshterm.ui.menus.marked_label` does the same for a
# menu row, for the same reason: a delete must not look like all other actions.
#
# The icon gets padding to the lane of this page. It is not written as
# `icon + " "`, because this page has a one-cell 🗑 and two-cell siblings. Without
# the padding, the label started one cell too 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 _bearing(lat1: float, lon1: float, lat2: float, lon2: float) -> str:
"""The 8-point compass direction from ``(lat1, lon1)`` to ``(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: the coordinates, and the range and bearing from our node, if it has a fix."""
text = Text(f"{lat:.4f}, {lon:.4f}", style="")
if self_lat is not None and self_lon is not None:
km = haversine_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:
"""Change the raw lat/lon that a device reports to a float, if possible (``None`` if not)."""
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 fix that a map can show.
The check is :func:`~meshterm.core.geo.usable_fix`. It refuses the 0/0 null island that
a node with no GPS reports. It also refuses the values out of range that a companion
with a bad configuration sometimes advertises (``lat -97, lon -1042``, heard on a real
mesh). Each of these puts the location preview on an all-black area outside the world.
"""
return lat is not None and lon is not None and usable_fix(lat, lon)
# -- data gathering + the action loop -----------------------------------------
class ContactPages:
"""The contacts whose node page a screen can open, and the page that opens on one of them.
This is the ^O key (``author_page``) of a conversation: the page of the node that wrote
the selected message. At each paint, the screen asks :meth:`find` for the contact of
the author. Thus its hint and its F-key lane name ^O only when the key acts. Then
:meth:`show` opens the page that the Contacts list opens for that contact. It is the
twin of :class:`~meshterm.ui.map_screen.Locator`, which ^L uses for the map.
The contacts come from the device one time, when the screen opens.
It is also the ^K key (``lock``) of a conversation: the lock of the author, which
:meth:`lock_state` reads and :meth:`set_locked` writes. The store keeps a lock under
the key of the device, so the pages hold that key too. The state is read from the store
at each paint and never kept here, because the node page can change it too.
"""
def __init__(self, ctx: AppContext, contacts: list[Contact], *, self_key: str = "") -> None:
"""Hold the contacts that :meth:`find` searches.
Args:
ctx: The shared application context (it must be in the interactive menu).
contacts: The contacts of the device.
self_key: The public key of the device (hex). The store keeps the locks under
it. With ``""``, no contact can be locked from here.
"""
self._ctx = ctx
self._contacts = contacts
self._self_key = self_key
self._dev_pub = self_key.lower().removeprefix("0x")
# The result of each key that find() got. A screen asks at each paint, and the
# answer for one key does not change while the contacts stay the same.
self._found: dict[str, Contact | None] = {}
@classmethod
async def gather(cls, ctx: AppContext, *, self_key: str = "") -> ContactPages:
"""The pages of the contacts that the device has now. With no contacts, nothing is found."""
try:
contacts = await ctx.devstate.contacts()
except Exception: # noqa: BLE001 - with no contacts, ^O opens only our own page
contacts = []
return cls(ctx, list(contacts), self_key=self_key)
def find(self, key: str | None) -> Contact | None:
"""The contact with ``key`` (a full key or a key prefix), or ``None``.
The rule is the same as the rule of the map (refer to
:meth:`~meshterm.ui.map_screen.Locator.find`). A key prefix finds the contact whose
full key starts with it, and a full key finds a contact that MeshTerm knows only by
its key prefix.
"""
if not key:
return None
needle = key.lower().removeprefix("0x")
if needle not in self._found:
self._found[needle] = next(
(c for c in self._contacts if _same_key(c.public_key or c.key_prefix, needle)),
None,
)
return self._found[needle]
@property
def can_lock(self) -> bool:
"""Whether a lock can be written from here at all: a store, and the key of the device."""
return getattr(self._ctx, "contact_store", None) is not None and bool(self._dev_pub)
def lock_state(self, contact: Contact | None) -> bool | None:
"""Whether ``contact`` is locked, or ``None`` when it cannot have a lock.
A lock needs the full key of a live contact, as on the node page (refer to
:meth:`~meshterm.core.contact_store.ContactStore.set_locked`). An archived contact
has no lock, because the lock exists to keep a contact out of the archive.
"""
if contact is None or not self.can_lock:
return None
key = (contact.public_key or "").lower().removeprefix("0x")
if len(key) != 64 or _is_archived(self._ctx, contact, self._self_key):
return None
return self._ctx.contact_store.is_locked(self._dev_pub, key)
def locked_keys(self) -> frozenset[str]:
"""The full keys of the contacts locked on this device, or none where no lock is stored.
A conversation compares it at each paint, to draw again the sender chips that gained
or lost their padlock (refer to :class:`~meshterm.ui.chat.ChatScreen`).
"""
if not self.can_lock:
return frozenset()
return self._ctx.contact_store.locked_keys(self._dev_pub)
def set_locked(self, contact: Contact, locked: bool) -> None:
"""Lock or unlock ``contact``. The caller asked :meth:`lock_state` first."""
if self.can_lock:
self._ctx.contact_store.set_locked(self._dev_pub, contact, locked)
async def show(self, contact: Contact | None) -> None:
"""Open the node page of ``contact``, or of our node when it is ``None``.
The page opens to look, not to manage (``manage=False``). The verbs that remove a
contact (archive, restore, delete) belong to the list that the contact is in, and a
conversation is not that list. The lock is the exception (``lock=True``): it removes
nothing, and a conversation can already toggle it with ^K (JP, 2026-10-19).
"""
await open_node_detail(self._ctx, contact, manage=False, lock=True)
def _same_key(contact_key: str | None, needle: str) -> bool:
"""Whether the key of a contact and a lowercase key (or key prefix) name the same node."""
key = (contact_key or "").lower().removeprefix("0x")
return bool(key) and (key.startswith(needle) or needle.startswith(key))
async def open_node_detail(
ctx: AppContext, contact: Contact | None, *, manage: bool = True, lock: bool | None = None
) -> bool:
"""Open the Node detail page for a contact (or our node) and run its action loop.
The function assembles the page from the stored history, the contacts of the device,
and the observed topology: the identity, the reception statistics, a location preview,
and the observed routes. Then it runs a loop. It shows the page, runs the action that
the user commits (trace, full map, time machine), and shows the page again, until the
user leaves with Esc. The Time Machine and the Contacts list use the same loop of show,
act, and show again.
**Three actions end the loop instead of a return to it**, because after each of them,
the page describes a state that is not true now. These actions archive the contact off
the device, restore it, and delete it permanently. All three are *contact management*.
They are the last group on the Info tab, and the page shows only the ones that apply.
The user can archive or delete a live contact, and restore or delete an archived
contact.
**A lock is management that stays.** The user can lock a live contact against an
archive (refer to :meth:`~meshterm.core.contact_store.ContactStore.set_locked`). Then
the sweep never counts it as a candidate, and this page removes its Archive row for as
long as the lock stays. The toggle writes the action rows of the page again in place,
and it does not end the visit, because nothing about the contact on the page is gone.
The list below draws its padlock again when the user comes back to it.
With ``manage``, a caller says that it opens the page to look, not to act. The archive
preview gives ``False``. A screen whose full purpose is to select what to archive must
not also give a second, single way to archive (or to delete) from its own list of
candidates. The rule is that the management verbs belong to the list that the contact
is really in. They never belong to a page opened from a list that is about to act on
all its rows together.
With ``lock``, a caller that does not manage still lets the page lock and unlock. A
conversation gives ``True`` (refer to :class:`ContactPages`): the lock removes nothing,
and the page that a chat opens is then the way to lock in a room, where the F-key lane
has no free chip for it.
Args:
ctx: The shared application context (the interactive TUI must be running).
contact: The contact to show, or ``None`` for our node (a page of identity and
ledger only, because our node never overhears itself, so there is no reception
history to show).
manage: Whether to show the contact-management actions (lock, archive, restore,
delete). ``False`` draws none of them, and then the page can only return
``False``.
lock: Whether to show ``Lock contact`` or ``Unlock contact``. ``None`` follows
``manage``.
Returns:
``True`` if the visit ended because the user archived, restored, or deleted the
contact. This tells the caller to read and build again the list that it came from.
``False`` on each ordinary exit.
Raises:
RuntimeError: If it is called outside the interactive menu (no full-screen
session).
"""
from ..services.markers import gather_markers
from ..services.topology import build_topology, is_path_hash
from ..services.trace_runner import (
make_name_key_resolver,
make_node_resolver,
make_node_type_resolver,
)
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 - the caller is only in the menu
raise RuntimeError("the node detail screen is only available in the menu")
session = ctx.ui.session
you = contact is None
# -- our node, for the "us" label, the centre of the location preview, and the range
# and bearing.
try:
info = await ctx.devstate.self_info()
except Exception: # noqa: BLE001 - the page is useful without our identity and 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 contacts of the device (to resolve the name, type, and key) and the width of the
# routing hash.
try:
contacts = await ctx.devstate.contacts()
except Exception: # noqa: BLE001 - resolution uses the stored data instead
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. Safe defaults keep the page working.
prefix_bytes, width_bytes = 0, 1
stored_names, named_at = ctx.repo.node_names_with_times()
# One resolver from hash to name for the full page. The route list and the route graph
# all name their hops through it (the contacts first, then the history of the recorder).
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
# -- the reception statistics and the first-heard time, from the history of the recorder.
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 # our node never overhears itself, so there is no reception history
# A node with a fix, but no location from the observations, still shows the map. But it
# does so only when that overheard fix is real (an advert with no GPS or with bad values
# 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 "?"
# -- the identity header.
if you:
glyph, glyph_style = SELF_MARK
name_hue = "you"
else:
glyph, glyph_style = NODE_GLYPHS.get(node_type, DEFAULT_GLYPH)
name_hue = name_style(name, key) if name else "muted"
# The mark is the identity in the title bar of a short frame. The header line adds the type.
mark = Text()
mark.append(f"{glyph} ", style=glyph_style)
mark.append(name or "unknown", style=name_hue)
header = mark.copy()
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_path_hash(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 (the identity, how the node was heard, and where it is). The routes
# go 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:
# The heard time of a contact is already merged with our reception history (refer
# to ``DeviceState._merge_heard``). Thus it is the later of the two, and it is what
# the lane of the contact list shows. Use it, and the two surfaces can never
# disagree about one node. Only a node that is not a contact, or a node whose merge
# could not read our history, uses the raw reception statistic instead.
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 that was never overheard shows 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 regions of a repeater: what it was last heard to relay, from the region store (its
# own answer to the regions request, or its table read on the admin page). Only a
# repeater answers the request, so only the page of a repeater asks it.
region_store = getattr(ctx, "region_store", None)
asks_regions = (
not you
and contact is not None
and node_type == NODE_TYPE_REPEATER
and bool(node_id)
and region_store is not None
)
routed = contact is not None and contact.route_hops is not None
if asks_regions:
info_rows.append(("regions", regions_value(region_store, node_id, routed=routed)))
# -- 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 only 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. The markers go 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 route list of the "routes heard" and the graph (node → us), with the path that
# has the best evidence in 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, heard=named_at),
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 routes data) 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 contact with only a key prefix (heard,
# but never synced from the device) has nothing that a camera can scan. Thus the row
# shows only when the full key is known.
full_key = key.lower().removeprefix("0x")
if len(full_key) == 64 and is_path_hash(full_key):
info_actions.append(_Action("share", "📱", "", "Share contact — QR / link"))
if asks_regions:
# It acts immediately (one anonymous request, one transmission), so no "…" at the
# end.
info_actions.append(_Action("regions", "🔖", "", "Ask which regions it carries"))
# -- contact management: the last group of the page, and the only actions that end the
# visit. None of them shows when the caller opened the page to look, not to act (refer
# to ``manage``), except the lock when the caller lets the page lock (refer to
# ``lock``). They never show for our node, or for a contact with no key at all,
# because the write has nothing to use as its address.
may_lock = manage if lock is None else lock
archived = (manage or may_lock) and _is_archived(ctx, contact, self_key)
keyed = not you and contact is not None and bool(contact.public_key or contact.key_prefix)
manageable = manage and keyed
store = getattr(ctx, "contact_store", None)
dev_pub = self_key.lower().removeprefix("0x")
whole_key = contact is not None and len(contact.public_key.lower().removeprefix("0x")) == 64
# A lock is a mark of MeshTerm itself, attached to the full key in the contact store, and
# only a live contact can have one. Its purpose is to keep a contact out of the archive.
lockable = (
may_lock and keyed and not archived and whole_key and store is not None and bool(dev_pub)
)
base_actions = list(info_actions)
def management(locked: bool) -> list[_Action]:
"""The action rows of the page, with the management group for this lock state last."""
return base_actions + _management_rows(
locked=locked,
lockable=lockable,
manageable=manageable,
archived=archived,
whole_key=whole_key,
)
locked = bool(lockable and store.is_locked(dev_pub, contact.public_key))
info_actions = management(locked)
try:
adv_type = int(node_type) if node_type is not None else 1
except (TypeError, ValueError):
adv_type = 1
# When routes are listed, each route row itself starts a trace (Enter prepares it). The
# separate action is shown instead only 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 with a delete of the contact. This tells the caller to build
# its list again.
contact_removed = False
# One screen for the full visit. The page is a *hub*: each action on it opens a different
# screen and comes back. Thus the page stays pushed while each action runs
# (``session.stay``), and it is not built again for each round. Before, each new build
# lost the open tab and the highlight. The user went to Routes with Tab, selected a
# route, traced it, and came back. Then the user was on Info at the top again, two key
# presses away from the route of the work. When the object stays, all of this stays.
# Also, the peers below *nest* above this page and do not replace it. Thus Esc from a
# trace comes back here, and ^W leaves the full sequence at one time.
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,
mark=mark,
)
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 the map centred on this node (the same place that the inline
# preview showed), not at the place where the global map was last left.
# Set the find filter to this node, so that it is lit among the others.
# Set the filter only when the label really matches a marker, because a
# search text that matches nothing makes all the markers dim. (The map
# action exists only 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 == "regions":
assert contact is not None and region_store is not None # offered only then
failed = await _ask_regions(ctx, contact, node_id, label, routed=routed)
screen.replace_info_row(
"regions",
regions_value(region_store, node_id, routed=routed, failed=failed),
)
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 it ends the visit for the same reason
# as a delete. The page was opened from the Archived list, and the row that
# it was opened from will not be in that list any more.
assert contact is not None # the row exists only for a real contact
if await _restore_archived(ctx, contact, self_key, label):
contact_removed = True
break
elif action in ("lock", "unlock"):
# No confirm and no notice. The same row that set the lock can reverse it,
# and the change of the row into its opposite is the acknowledgement.
assert contact is not None and store is not None # offered only when lockable
locked = action == "lock"
store.set_locked(dev_pub, contact, locked)
screen.replace_info_actions(management(locked))
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. Thus the
# user confirms while the node is visible. A removal ends the visit, because
# there is no contact left to show, and the list that MeshTerm goes back to
# is built again without it.
assert contact is not None # the row exists only for a real contact
contact_removed = await _remove_contact(ctx, contact, self_key, label)
if contact_removed:
break
if minimap is not None:
# The braille of the preview may have left marks on the terminal (double-width
# fallback glyphs that the diff of prompt_toolkit cannot see). Force one clean paint
# of the list below, exactly as the full map does when it closes.
session.request_full_repaint()
return contact_removed
def _management_rows(
*, locked: bool, lockable: bool, manageable: bool, archived: bool, whole_key: bool
) -> list[_Action]:
"""The contact-management group of the node page, for one lock state.
The lock is first. It changes nothing on the device and ends nothing, so it is the
safest row of the group for the highlight. Then the constructive verb, then the
destructive verb. Thus a key press by mistake most probably goes to the row that the
user can undo. Archive and restore are the two halves of one move that the user can
reverse. They use the ``💾``/``📂`` pair that the lexicon already gives to "put away"
and "bring back". Only a contact with a full key can be written back to the device.
Thus a contact with only a key prefix never gets the archive, because the archive
leaves it with no way back. A locked contact never gets the archive at all, and that
is the purpose of the lock.
A page that may lock but not manage (a page that a conversation opens) has only the
lock row.
Args:
locked: Whether the contact is locked now.
lockable: Whether the page shows the lock row.
manageable: Whether the page shows archive, restore, and delete.
archived: Whether the contact is archived. Then restore takes the place of archive.
whole_key: Whether the full key of the contact is known, which an archive must have.
"""
rows: list[_Action] = []
if lockable:
if locked:
rows.append(_Action("unlock", "🔓", "", "Unlock contact"))
else:
rows.append(_Action("lock", "🔒", "", "Lock contact"))
if not manageable:
return rows
if archived:
rows.append(_Action("restore", "📂", "ok", "Restore to device"))
elif whole_key and not locked:
rows.append(_Action("archive", "💾", "", "Archive contact"))
rows.append(_Action("remove", "🗑", "err", "Delete contact…"))
return rows
def _is_archived(ctx: AppContext, contact: Contact | None, self_key: str) -> bool:
"""Whether the sweep removed this contact from the device, and MeshTerm keeps it.
The value is read directly from the contact store, and the caller does not give it.
Thus the page has the correct answer from each place that it can be opened from (the
``Archived`` section of the Contacts list, a map marker, a route row), not only from a
place where the caller knew the answer.
"""
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`` when it is off the device.
This is the single-contact counterpart of the bulk sweep (refer to
:func:`~meshterm.ui.sweep_screen.archive_contacts`). It does exactly what the sweep does
to each contact that it removes. It removes the contact from the device, then stores it
in the cross-session store with an archive stamp, so that nothing is really lost.
The confirm is **amber, not red, and it asks for no typed text**. The two caution tiers,
one above the other, are for two different costs, and this cost is recoverable. The
contact keeps its key, its reception history, and its transcripts. The Archived list is
one row away on the Contacts screen, and a restore is one write. Red and a typed word
are for :func:`_remove_contact`, which is the action that cannot be undone.
Args:
ctx: The shared application context (interactive menu, with the page as the
backdrop).
contact: The contact to archive, addressed by the key that it carries.
self_key: The public key (hex) of the device itself. The contact store uses it
to keep the remembered contacts of each device apart.
label: The name of the node 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 does not have the contact is not a refusal: the archive
completes on our side, because the contact is not on the device, and that is the
state that the archive asked for.
"""
from .surface import TuiUi
assert isinstance(ctx.ui, TuiUi) # open_node_detail makes sure of this
session = ctx.ui.session
# The contact is named as the Contacts list names it: the type mark in its own colour,
# then the name in the hue that comes from its key. (A bare id that is shown for a
# contact with no name is ``node.unknown``.) Each span of the prose has the amber,
# instead of the base style of the prompt, because Rich merges the bold of the base
# style into the mark.
glyph, glyph_style = NODE_GLYPHS.get(contact.node_type, DEFAULT_GLYPH)
prompt = Text()
prompt.append("Archive ", style="warn")
prompt.append(f"{glyph} ", style=glyph_style)
prompt.append(
label,
style=(
name_style(label, contact.public_key or contact.key_prefix)
if contact.name
else "node.unknown"
),
)
prompt.append(
"? 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.",
style="warn",
)
if not await ctx.ui.dialog(
prompt,
[("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 device does not have the contact, and the archive wanted that result. The
# store write below is all the work that remains.
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 to the device. ``True`` when it is live again.
This is the inverse of the Contacts sweep, for one contact at a time (refer to
:mod:`~meshterm.ui.sweep_screen`). The device write comes **first**, and the archive
mark in the store is cleared only after the write succeeded. A failed write must never
leave a contact listed as live on a device that does not have it. That row is a contact
that the user cannot send a message to and cannot restore.
No confirm: a restore is constructive, the sweep that archived the contact can reverse
it, and it uses one contact slot. The dialog stays for the failures. They are the only
part that the user cannot see from the list when the list comes back with the row in it.
Args:
ctx: The shared application context (interactive menu, with the page as the
backdrop).
contact: The archived contact to write back.
self_key: The public key (hex) of the device itself.
label: The name of the node 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) # open_node_detail makes sure of this
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 remove one contact from the device. ``True`` when it is gone.
This is the single-contact counterpart of the bulk sweep of the Contacts list (refer to
:func:`~meshterm.ui.sweep_screen.archive_contacts`). But it deletes, where the sweep
archives. It is the way to make MeshTerm forget a node completely. Thus it removes the
contact in two places, because the list that a screen sees is the *union* of the two:
the contact table of the device itself, and the contacts that MeshTerm remembers for
that device (refer to :mod:`meshterm.core.contact_store`). If only the first forgets
the contact, the store merges the contact back at the next read. Then the delete looks
like a screen that did nothing.
Only the *contact* goes. The reception history of the node, its overheard traffic, and
the chat messages with it all belong to MeshTerm, and they do not change. What is lost
is the ability of the device to address the node. Thus, when a chat sends to a contact
that the firmware does not have now, it offers to write the contact back and does not
fail (refer to :func:`~meshterm.ui.chat._restore_contact`).
**It cannot be undone, and the confirm says so.** Nothing here can derive again a key
that the store forgot. Thus this is the only contact action with no way back, and that
is exactly what makes it different from :func:`_archive_contact`, whose amber confirm
is one row above it on the same page. It has the red that is reserved for data loss:
Cancel on the left, and the committing Delete on the right as the default.
Args:
ctx: The shared application context (interactive menu. The caller pushes the page
as the backdrop of the confirm).
contact: The contact to delete, addressed by the key that it carries.
self_key: The public key (hex) of the device itself. The contact store uses it
to keep the remembered contacts of each device apart.
label: The name of the node 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 always shown, because a contact that is still on the device
must not go away from the list. A device that does not have the contact is not a
refusal: the removal completes on our side and says so
(:class:`~meshterm.core.connection.ContactNotOnDeviceError`).
"""
from .surface import TuiUi
assert isinstance(ctx.ui, TuiUi) # open_node_detail makes sure of this
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:
# There is nothing to delete on the device. The entry existed only in what MeshTerm
# remembers for this device (or the firmware removed it after MeshTerm read the
# table). That is not a failed removal. The contact still goes, and MeshTerm tells
# the user why the device had no part in it. If not, "removed" is a claim about a
# device that never had the contact.
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 table of the device changed, and the session cache does not have the change. The
# list that MeshTerm goes back to reads it again.
ctx.devstate.invalidate_contacts()
# No notice of success. The result of the archive sweep is a count that nobody can know
# before, but this result is clear. The page of the contact closes, and the list behind
# it comes back without the row. That says it better than a dialog that the user must
# close.
return True
#: Why a repeater may not answer the regions request, in the words that the page uses.
_REGIONS_REACH = "it answers only a neighbour, or over a known route"
def regions_value(store, node_id: str, *, routed: bool, failed: bool = False) -> Text: # noqa: ANN001 - RegionStore
"""The ``regions`` vital of a repeater: what it relays, whether also unscoped, and its age.
Four states, each in its own words, so that no state reads as a different one:
* **Answered**: the regions that it named, then whether it also relays *unscoped*
floods (``unscoped too`` or ``scoped only``, from the ``*`` at the start of its
answer, which names no region), then when it said so. A repeater that relays
unscoped floods and no region reads ``unscoped floods only``. A repeater that named
nothing reads that it relays no floods.
* **Not asked**: nothing is stored (``not asked yet``). A repeater with no known route
also says why the question may get no answer (the repeater answers the request only
when it arrives direct).
* **No answer**: the question was asked on this visit and no answer came back, from a
repeater that never answered before. The row also says why the question may get no
answer.
* **No answer now**: the question was asked on this visit and no answer came back,
but the repeater answered before. The row keeps the earlier answer and adds
``no answer now``. The age of the earlier answer shows that it is stale, because a
repeated question with no answer says nothing about what the repeater carries.
Args:
store: The region store.
node_id: The 12-hex id of the repeater.
routed: Whether the device has a route to it (a neighbour or a learned path).
failed: Whether the question on this visit got no answer.
Returns:
The value of the row.
"""
answer = store.answer_of(node_id)
if answer is None:
if failed:
text = Text("no answer", style="warn")
text.append(f" — {_REGIONS_REACH}", style="muted")
return text
text = Text("not asked yet", style="muted")
if not routed:
text.append(f" — {_REGIONS_REACH}", style="muted")
return text
names = store.carried_by(node_id)
text = Text()
if names:
text.append(", ".join(names))
text.append(" · " + ("unscoped too" if answer.unscoped else "scoped only"), style="muted")
elif answer.unscoped:
text.append("unscoped floods only")
else:
text.append("none — it relays no floods", style="warn")
text.append(f" · answered {format_ago(age_seconds(answer.answered_at))}", style="muted")
if failed:
text.append(" · no answer now", style="warn")
return text
async def _ask_regions(
ctx: AppContext, contact: Contact, node_id: str, label: str, *, routed: bool
) -> bool:
"""Ask a repeater one time which regions it carries, and learn the answer.
One anonymous request and one transmission, under the busy overlay. The answer replaces
what the store had for this repeater
(:meth:`~meshterm.core.region_store.RegionStore.learn_carried`). When the request gets
no answer, a dialog says so, with the most probable reason: the repeater ignores the
request if it does not arrive direct, and the repeater limits the rate of these
requests. Nothing tries the request again.
Returns:
``True`` when the repeater did not answer.
"""
device = await ctx.device()
try:
async with ctx.ui.busy_overlay():
names = await device.request_regions(contact)
except DeviceCommandError as exc:
where = "" if routed else " There is no known route to it, so only a neighbour answers."
await ctx.ui.session.message_dialog(
Text(f"{exc}{where}", style="warn"),
title=f"Regions — {label}",
)
return True
ctx.region_store.learn_carried(node_id, names)
return False
def _signal_row(hn) -> Text | None: # noqa: ANN001 - Optional[HeardNode]
"""The median and best reception SNR and the last RSSI, or ``None`` if 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:
"""The hash of a key 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, with no strict type, like the graph callbacks
node_known: bool,
self_name: str | None,
hash_bytes: int,
) -> tuple[Text, Text]:
"""One route as ``(path, context)``: the path line, and the line of context under it.
The full route reads from left to right in the direction of the graph (the contact on
the left, our node on the right), so the row and the drawn line refer to each other.
``path`` is one :class:`~meshterm.ui.pathline.PathLine`, drawn exactly as the Message
paths dialog draws its arrivals (JP, 2026-08-09): **the hops show as names**. Each hop
that the resolver can place shows its contact name in the hue that comes from the key
of that node, as powerline chips where the terminal can draw them. Our own end is the
``★`` of the whole app. Each route on this screen ends on our node, so the star says
this in one cell and leaves the rest of the lane to the hops that are different from
row to row. (The trace route lane and the trophy card make the same choice, and the
graph above puts the same mark on our end.)
A hop that has no known name shows its own hash instead, at the path-hash-mode width of
the device (the width that the radio itself carries for each hop). It is in the grey of
the whole app for an unknown node, because colour is the signal for "this is a name",
exactly as in the graph above. No hop repeats its hash after its name. The hash bytes
are in the graph, one band up, and the shared node hue connects a row to the graph,
not a second copy of the hex.
Names use cells that hashes do not use, and the horizontal scroll of the row is for
this reason (refer to :meth:`NodeDetailScreen._route_row_lines`). The rows of Message
paths make the same choice, and a name is worth it: a route reads as places, and the
user pays only for the one row that is highlighted. ``context`` starts with the hop
count of the route (:func:`~meshterm.ui.pathline.hops_atom`: the number that the line
above encodes but never states, and the first number by which the user compares two
routes). Then the bottleneck SNR, the sample count, and a ``★ best`` or
``device route`` tag that marks the winner and the route that the firmware learned.
All of these are kept off the path line itself, so a long chain never pushes them
out. Because of the count, the row always has a context line, and that is correct:
each route has a length. Before, a row whose second line came and went with the
evidence made the list one row taller when the highlight moved.
"""
hash_bytes = max(hash_bytes, 1) # an unknown mode still must have 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, in the 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 and the label under which a contracted bidirectional cluster is drawn.
A homogeneous cluster (all the members have the same node type) gets the map marker and
the colour of that type (``3 repeaters`` under a ``▲``). Thus the user immediately sees
a group of that type. A mixed cluster uses the plain node dot and ``n nodes`` instead.
"""
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 bidirectional cluster of 3 or more in the draw sequences into one super-node.
A dense knot of repeaters that relay each other is a strongly connected component that
the flow graph cannot put in order (refer to :func:`~meshterm.ui.pathgraph.bidir_clusters`).
If it is not changed, all the members collapse onto one crowded column. Thus each such
cluster is contracted to one synthetic node. In the ``draw`` of each route, the members
are written again as the one cluster id (members that follow each other are merged).
The returned map gives each cluster its marker and label.
Only ``draw`` (the shape of the graph) changes. The ``row`` of each route, with all the
names, and its trace ``spec`` keep each member named in order. Thus the list under the
graph still shows the exact order that the single marker cannot show, and a trace still
prepares 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 sequence of members of 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 selectable routes (node → us) and graph callbacks of the Routes tab, or a note.
The routes are drawn with the contact on the left and our node on the right. That is the
*inbound* direction in which the packets travelled to our node, because all that the
graph knows was received, not sent. The shared
:func:`~meshterm.ui.widgets.route_graph_style` already draws in exactly this direction.
(It was built for the Message paths dialog, where traffic arrives at our node on the
right.) Thus this function gives it the target as its ``source`` (the left end) and
uses its callbacks unchanged. It only reverses the hop order of each route, so that the
drawn line goes from the contact in to our node.
The selectable list, strongest first, puts the old ``route`` and ``suggest`` info rows
into the tab. First the route with the best evidence (the observed suggestion, or if
there is none, the route that the firmware learned, or if there is none, a direct
route). Then the route that the firmware learned, when it is different. Then the *good*
observed alternatives. Only good alternatives are kept. An observed route stays when its
evidence is **fresh** (its stalest hop was heard in :data:`_PATH_STALE_DAYS`) and it is
**not an outlier** (its score is in :data:`_PATH_OUTLIER_RATIO` of the strongest
observed route). Thus the list has the routes that the user can trust, not each chain
that was ever heard. With no route evidence at all (no learned route, no observed path,
not even a direct link), there is nothing true to draw. Then a muted note is shown
instead, and the tab still offers an automatic trace.
The live selection of the screen sets which route is white (and which nodes keep the
hue of their name), at each render. This function 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 that the device only ever hears directly (no relays, no learned route) still
# gets a line: the direct route with zero hops. Thus the graph shows the direct link, not
# only a note.
if best_hops is None and topo.link(topo.self_id, canonical_target) is not None:
best_hops = ()
# The selectable routes, in order and with no duplicates (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 marks of the relays with a known type. Thus the real types of
# the members decide it, before the contraction folds the members of a dense cluster
# behind one synthetic id.
legend = any(type_of(h) is not None for route in routes for h in route.draw)
# Fold each bidirectional cluster of 3 or more (a knot that the flow cannot put in order)
# into one super-node. Thus it is drawn as one marker, and its members are not put on one
# column. The row and the spec of each route keep the members named in order. Only the
# drawn shape 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 graph of Message paths does (JP, 2026-08-09): the two
# ends by name, and each relay by only the first byte of its hash. Names here were the
# clear choice, until a busy graph drew them. Each label is above or below its own
# marker, and a long name goes across the graph lanes on each side of it. Two cells cannot,
# so the graph stays readable with any number of routes. The names are now in the row
# list under the graph. The byte of a relay and its row chip refer to each other by hue,
# and the same node is not written two times. A contracted cluster keeps its own label
# with the count and the type, because 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 has its own map glyph (▲ repeater, ■ room, ◉ sensor), the same mark that
# the header and the map give it. route_graph_style draws the far (left) end as a plain
# dot, because on its home screen (Message paths) that end is the origin of any message.
# Here it is a known contact, and its type can be shown. A contracted cluster draws its
# own type mark. All the other nodes keep the glyph of the style.
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 to draw: fresh, with evidence, and not outliers.
The function makes the raw list of scenarios smaller, to the alternatives that the graph
must show as grey alternatives:
* Only the **observed** family. The device and direct scenarios are not routes on which
the evidence *observed* the node arrive. The device route has its own row, and a
direct line that the evidence never observed does not get a graph lane.
* Only routes with real evidence (a positive score. A link that was not observed has a
score of zero, refer to :meth:`~meshterm.services.topology.MeshTopology._score_route`).
* Only **fresh** routes: each hop was heard in :data:`_PATH_STALE_DAYS`. Thus a route
whose weakest link has gone quiet is removed. It does not stay as a line that may not
carry packets now.
* Only routes that are **not much weaker** than the best observed alternative (the score
is in :data:`_PATH_OUTLIER_RATIO` of the strongest). Thus one route that is clearly
the best is not hidden under many routes that are only marginal.
Returns the remaining routes in the order that
:meth:`~meshterm.services.topology.MeshTopology.scenarios` gave 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 each link on ``us → hops… → target`` was heard in the stale limit.
A route is only as current as its stalest hop. If a link on it was not heard in
:data:`_PATH_STALE_DAYS`, the chain may not carry packets now, so the full route counts
as stale. A link with no timestamp (evidence that carries no ``when``, for example a
firmware route) counts as fresh, because there is no age to use 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 # evidence with no date, so no age to find it stale
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 glyph callback of the graph, so that the target end draws its own node glyph.
The left end of the graph (``SRC_NODE``) is the node of this page, and here its type is
known. Thus it draws the mark of the map for that type (``▲`` repeater, ``■`` room,
``◉`` sensor, ``●`` plain), the same as the identity header and the location preview,
and not the generic origin dot of ``route_graph_style``. All the other nodes go through
unchanged (our own star on the right end keeps the ``you`` mark of the style).
"""
def wrapped(node: str) -> tuple[str, str]:
return target_glyph if node == SRC_NODE else glyph_of(node)
return wrapped