Files
MeshTerm/meshterm/ui/records_screen.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

926 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The Trophy case: the record-setting walks the trace tools have turned up.
A read-only board over the ``discovered_paths`` table (:meth:`Repository.discoveries`),
opened from the main menu. Every successful trace — a *Trace target* boomerang or a
*Trace path* walk — is scored against every discipline and offered to their boards
(see :mod:`~meshterm.services.records`); this screen is where the survivors live.
* the browser groups the disciplines, each under its heading with a one-line
description of the game (word-wrapped when it must) — the two pin overhead together as
one block while that board scrolls, so a row deep in a discipline still says which game
it is winning and what that game scores — then the discipline's records ranked
best-first: the day, the score in the discipline's own unit, and the walk itself on THE
path widget with both ends bare (every record is a boomerang, so the cells go to the
hops). A discipline holding records at more than one hash width tags each row with its
width, since the widths are genuinely different games;
* opening a record floats :class:`RecordDialog` — every stat the walk was measured by
(the far point named with the node it reached, the longest leg with the link it spanned),
the walk drawn two ways: on THE route graph (the Message paths dialog's shape, us at both
ends, drawn no taller than one lane
needs) and, beside the stats, as the enclosed area it swept on a braille mini-map (us and
every positioned hop, coloured node pins, no labels); then the full route — the path
widget again, unlabelled across the card's whole width, wrapping at hop boundaries — and
the spec, and when/by which app version it was
set. The card scrolls (PgUp/PgDn/Home/End) when it outgrows the terminal. From there
*Trace this path* reopens Trace path with the record's route prefilled, so a claim
worth re-testing is one Enter from the air again — and when that screen closes, the
flow unwinds to the main menu rather than re-entering the browser, so the user is
never left many Escs deep;
* deletion comes in three grains, each behind a red data-loss dialog: one record
behind a Cancel/Delete confirm, and — the bulk grains — one discipline (every width)
or everything, each gated behind typing ``delete``.
Nothing here transmits: it reads the boards the trace tools filled.
"""
from __future__ import annotations
import textwrap
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING
from rich.cells import cell_len
from rich.text import Text
from ..core.geo import haversine_km
from ..persistence.repository import DiscoveredPath
from ..services import trace_runner
from ..services.records import CATEGORIES, CATEGORY_BY_ID, Category, _local_xy
from .mapcanvas import RGB, MapCanvas
from .menus import fit_cells, marked_label, section_heading
from .pathgraph import PathLayer, render_path_graph
from .pathline import path_line
from .theme import name_style, snr_style
from .tui.render import render_hanging, render_to_ansi
from .tui.screen import Screen
from .widgets import (
NodeResolver,
TypeOf,
name_rgb,
node_marker,
node_type_legend,
route_graph_style,
self_marker,
)
if TYPE_CHECKING:
from ..context import AppContext
#: The width the discipline descriptions wrap at — comfortably inside 72 columns so the
#: prose reads the same however wide the terminal is.
_DESC_WRAP = 64
#: The colour a record's single walk draws in on the route graph. It is the only path
#: on the canvas, so it needs no colour to set it apart — white just reads as "the walk".
_WALK_EDGE = (255, 255, 255)
#: Canvas rows a record's route graph draws in. One walk is one lane, so the graph is flat
#: and needs only its marker row with a row either side for the labels — the shared widget's
#: own default (5) leaves a scored walk sitting in two rows of blank canvas. A fan would grow
#: past this on its own; nothing here ever fans.
_GRAPH_ROWS = 3
#: The area drawing's one body tone — a plain muted slate the enclosed region and its loop
#: both draw in. Deliberately a single shade (no dimmer interior wash under a brighter rim):
#: an even-odd fill shades only the enclosed side, and the coloured node pins carry the
#: colour, so the shape reads as one flat silhouette rather than two brightnesses.
_AREA_TONE: RGB = (148, 163, 184)
#: The area drawing's cell box, to the right of the stats: a width that scales with the
#: dialog but stays inside these bounds, extra rows spent below the stats so the shape has
#: room, and a floor of columns below which it is dropped (the stats reclaim the full
#: width) rather than squeezed into illegibility.
_POLY_MIN_W = 16
_POLY_MAX_W = 28
_POLY_EXTRA_ROWS = 2
_SIDE_BY_SIDE_MIN = 44
#: Cells the record card's label lane spans. Every labelled row on the card — the stat
#: lanes, the spec row's hanging indent, the recorded line — shares this one column, so
#: the values align whatever the label says.
_LABEL_W = 12
def _drawn_rows(lines: list[str]) -> list[str]:
"""``lines`` with the rows nothing actually landed on stripped from either end.
Both blocks the card stacks are drawn on a canvas sized for the worst case and then
filled: the stats' area drawing keeps :data:`_POLY_EXTRA_ROWS` in hand for a tall shape,
and the route graph pads a row either side of its markers for the labels. A shape that
comes out flat, or labels that all seat on one side, leave those rows empty — and an
empty row still costs a line of card, stacking up as a gap the layout never intended
(three blank lines between the stats and the graph where one was meant). Trimming happens
*after* the drawing, so nothing placed is ever lost: only rows that stayed blank go.
"""
kept = list(lines)
while kept and not Text.from_ansi(kept[-1]).plain.strip():
kept.pop()
while kept and not Text.from_ansi(kept[0]).plain.strip():
kept.pop(0)
return kept
@dataclass(frozen=True, slots=True)
class WalkVertex:
"""One positioned point of a walk's circuit, projected onto the local km plane.
Our own node sits at the origin and every hop is placed relative to it (east/north
kilometres), so the vertices draw the same shape the area was scored over. Each
carries its own map marker so the drawing pins nodes in the shared palette.
Attributes:
x: Kilometres east of our node.
y: Kilometres north of our node.
glyph: The node-type marker to pin at this point.
color: The marker's truecolour.
is_self: Whether this vertex is our own node (the yellow star at the origin).
"""
x: float
y: float
glyph: str
color: RGB
is_self: bool
class RecordDialog(Screen):
"""One record's full story, floating over the screen beneath.
Every stat the walk was measured by — a reliability read from the far node's trace
history, the far point named with the node it reached, the longest leg drawn as the
link it spanned (our own end on the app-wide ★) — with the walk's enclosed area
drawn beside them on a braille mini-map (us at the origin, every positioned hop pinned
in its own name hue, no labels) when the terminal has the room; below, the walk on THE
route graph (us at both ends, relays wearing their map marker over a node-type key),
the route in full on THE path widget — no label lane, the card's whole width, wrapped
at hop boundaries and never truncated — and the
record's provenance — when it was set and by which app version. The card scrolls
(PgUp/PgDn/Home/End) when it outgrows the frame, while the arrows drive the actions.
Two actions besides Back: *Trace this path* reopens Trace path with the record's route
prefilled (propagation shifts; a record is a claim worth re-testing), and *Delete…*
removes this one record behind a red Cancel/Delete data-loss confirm.
"""
def __init__(
self,
record: DiscoveredPath,
category: Category,
rank: int,
*,
resolve: NodeResolver,
device_label: str,
device_hash: str | None,
far_label: str | None = None,
far_id: str | None = None,
shape: Sequence[WalkVertex] | None = None,
reliability: tuple[float, int, int] | None = None,
type_of: TypeOf | None = None,
) -> None:
"""Build the dialog for one stored record.
Args:
record: The record to show.
category: Its category (for the score's unit and title).
rank: Its standing on the board (1 = the record holder).
resolve: Maps node ids to friendly names.
device_label: Our node's name, bracketing the route.
device_hash: Our public key, annotated at the record's width.
far_label: The farthest node's name, shown beside its distance; ``None`` when
no positioned hop was named.
far_id: The farthest node's id, seeding the name's hash-derived hue; ``None``
leaves the label on the name-derived fallback.
shape: The walk's positioned circuit, projected for the area drawing; ``None``
below the three points a polygon needs.
reliability: ``(rate, successes, total)`` of traces to the walk's far node, or
``None`` when that node was never a trace target.
type_of: Maps a route hash to its node type, so relays draw their map marker
(``▲`` repeater, …) on the graph; ``None`` falls back to generic dots.
"""
super().__init__()
self.title = f"Record — {category.title} #{rank}"
self.footer_hint = "↑↓ move · PgUp/PgDn scroll · Enter select · Esc back"
self._record = record
self._category = category
self._resolve = resolve
self._device_label = device_label
self._device_hash = device_hash
self._far_label = far_label
self._far_id = far_id
self._shape = list(shape) if shape else None
self._reliability = reliability
self._type_of = type_of
self._actions = ("trace", "delete")
self._index = 0
self._cursor: int | None = None
# The composed card above the action rows, per width (see render_body).
self._upper_cache: tuple[int, list[str]] | None = None
# The card opens at the top, reading down; the arrows drive (and follow) the action
# cursor, while PgUp/PgDn/Home/End scroll the body free of it (see cursor_line).
self._follow = False
@property
def dialog_width(self) -> int:
"""A comfortable reading width; the compositor still caps it to the frame."""
return 62
def handle(self, action: str, data: str = "") -> None:
"""Move the action cursor, scroll the card, commit the selection, or dismiss."""
if action == "up":
self._follow = True
# Both ends clamp rather than wrap — the app-wide rule for a row cursor:
# a highlight that leaps end to end takes the results window with it.
self._index = max(0, self._index - 1)
elif action == "down":
self._follow = True
self._index = min(len(self._actions) - 1, self._index + 1)
elif action in ("pageup", "ctrl_pageup"):
self._follow = False
self.scroll_pages(-1)
elif action in ("pagedown", "space", "ctrl_pagedown"):
self._follow = False
self.scroll_pages(1)
elif action in ("home", "ctrl_home"):
self._follow = False
self.scroll_to_top()
elif action in ("end", "ctrl_end"):
self._follow = False
self.scroll_to_bottom()
elif action == "enter":
self.resolve(self._actions[self._index])
elif action == "escape":
self.resolve(None)
def cursor_line(self) -> int | None:
"""Keep the selected action visible while arrowing; scroll free once paging."""
return self._cursor if self._follow else None
@staticmethod
def _label(label: str) -> Text:
"""The card's muted label lane, padded to :data:`_LABEL_W` cells."""
return Text(f"{label:<{_LABEL_W}}", style="muted")
def _lane(self, label: str, value: Text) -> Text:
"""One label/value stat lane (label lane fixed so values align)."""
row = self._label(label)
row.append_text(value)
return row
def _graph_lines(self, width: int) -> list[str]:
"""Draw the walked route as one path on THE route graph — us at both ends.
A scored walk leaves home and comes back, so it draws us (left) → its relays →
us (right) as a single white path through the shared route-graph widget
(:func:`~meshterm.ui.pathgraph.render_path_graph`), the same shape the Message
paths dialog draws a delivery over. Relays wear their own map marker (``▲``
repeater, …) where the type is known. Nodes the walk passed through more than once
— a boomerang's mirrored return leg — collapse to their first appearance, and the
route line below still carries every hop, revisits and all.
That collapse is a *deliberate* choice here, not the widget's limit: a graph can draw
each visit its own marker (``allow_duplicate_nodes``, which the observed-packet surfaces
turn on), and this one declines. A trophy record scores a path **we composed**, so
re-walking one node must never make the shape look bigger than the ground it covered —
one node, one marker, whatever the spec asked for. An overheard via chain has the
opposite duty: nothing there is ours to inflate, and a repeat is evidence.
One walk is one lane, so the graph draws in :data:`_GRAPH_ROWS` — the marker row and
a row either side for the labels — rather than the widget's default, which reserves
room for a fan this canvas never carries and spends it on blank rows. Whichever of
those two label rows stays empty is handed back (:func:`_drawn_rows`), so the graph
sits exactly one blank line under the stats and the caption follows it directly.
"""
seen: set[str] = set()
hops: list[str] = []
for node in self._record.route:
if node not in seen:
seen.add(node)
hops.append(node)
glyph_of, label_of, label_rgb_of = route_graph_style(
resolve=self._resolve,
self_name=self._device_label,
source=self._device_label,
type_of=self._type_of,
)
return _drawn_rows(
render_path_graph(
[PathLayer(hops=tuple(hops), color=_WALK_EDGE, priority=3)],
width,
glyph_of=glyph_of,
label_of=label_of,
label_rgb_of=label_rgb_of,
min_rows=_GRAPH_ROWS,
)
)
def _stat_lanes(self) -> list[Text]:
"""Every stat the walk was measured by, as label/value lanes (score → round trip).
Which lanes appear varies by discipline — a walk with no positions has no distance,
far point, or area — so callers size to the returned list rather than a fixed count.
The far-point lane carries the reached node's name when one is known, and the
longest-leg lane the link it spanned; a record set before a stat existed simply has
no lane for it.
"""
record, category = self._record, self._category
stats = record.stats
lanes: list[Text] = []
score = category.format_score(record.score)
if category.id == "long_haul" and not stats.get("km_complete", True):
score = "≥ " + score
lanes.append(self._lane("score", Text(score, style="accent bold")))
if self._reliability is not None:
rate, ok, total = self._reliability
value = Text(f"{rate:.0%}", style=snr_style(20 * rate - 10))
value.append(f" · {ok}/{total} traces to far node", style="muted")
lanes.append(self._lane("reliability", value))
hops = stats.get("hop_count", len(record.route))
distinct = stats.get("distinct_nodes", len(set(record.route)))
walk = Text(f"{hops} hop{'s' if hops != 1 else ''}")
walk.append(f" · {distinct} distinct node{'s' if distinct != 1 else ''}", style="muted")
if stats.get("repeats"):
walk.append(" · revisits", style="muted")
lanes.append(self._lane("walk", walk))
km = stats.get("km_travelled")
if km:
value = Text(f"{'≥ ' if not stats.get('km_complete', True) else ''}{km:.1f} km")
lanes.append(self._lane("distance", value))
far = stats.get("far_km")
if far is not None:
value = Text(f"{far:.1f} km")
if self._far_label:
value.append(" ")
value.append(self._far_label, style=name_style(self._far_label, self._far_id))
lanes.append(self._lane("far point", value))
leg = stats.get("leg_km")
if leg is not None:
value = Text(f"{leg:.1f} km")
link = stats.get("leg_link")
if link and len(link) == 2:
# The link itself on THE path widget, both ends named the way the walk
# below names them — our own end on the app-wide ★, since a leg that
# starts or ends at home is the commonest kind there is.
value.append(" ")
value.append_text(
path_line(
list(link),
self._resolve,
self_name=self._device_label,
bare_self=True,
dim_self=False,
show_hash=False,
).text()
)
lanes.append(self._lane("longest leg", value))
area = stats.get("area_km2")
if area is not None:
lanes.append(self._lane("area", Text(f"{area:.1f} km²")))
snr = stats.get("min_snr")
if snr is not None:
lanes.append(self._lane("weakest", Text(f"{snr:+.1f} dB", style=snr_style(snr))))
rtt = stats.get("rtt_ms")
if rtt is not None:
lanes.append(self._lane("round trip", Text(f"{rtt:.0f} ms")))
return lanes
def _compose_stats(self, lanes: list[Text], width: int) -> list[str]:
"""Lay the stat lanes out, the area drawing pinned to their right when it fits.
With a drawable walk and room to spare, the polygon takes a fixed cell box on the
right and the lanes are cropped to the column beside it; too narrow, or no shape,
and the lanes reclaim the whole width and the drawing is dropped. The block is
trimmed to the rows something landed on (:func:`_drawn_rows`), so a shape that comes
out flat hands its unused rows back instead of leaving the card gaping under the
last stat.
"""
if not self._shape or width < _SIDE_BY_SIDE_MIN:
return [render_to_ansi(lane, width, no_wrap=True) for lane in lanes]
poly_w = min(_POLY_MAX_W, max(_POLY_MIN_W, width // 3))
left_w = width - poly_w - 2
# Spend a couple of rows beyond the stat lanes so the shape has room; the extra
# rows hang just below the last stat, in the air above the route graph.
poly = self._shape_lines(poly_w, len(lanes) + _POLY_EXTRA_ROWS)
out: list[str] = []
for i in range(len(poly)):
row = lanes[i].copy() if i < len(lanes) else Text()
row.no_wrap = True
row.truncate(left_w, overflow="ellipsis", pad=True)
row.append(" ")
row.append_text(Text.from_ansi(poly[i]))
out.append(render_to_ansi(row, width, no_wrap=True))
return _drawn_rows(out)
def _shape_lines(self, cell_w: int, cell_h: int) -> list[str]:
"""Draw the walk's enclosed area on a braille canvas: fill, loop, coloured pins.
The projected circuit (us at the origin, every positioned hop around it) is scaled
to the cell box preserving true proportions — braille dots are square, so equal x/y
scaling keeps the geography honest — then filled as a shaded region, outlined as the
walked loop, and pinned with each node's map marker. No labels: the pins carry the
node types and the stats beside them carry the numbers.
"""
verts = self._shape or []
canvas = MapCanvas(cell_w, cell_h)
xs = [v.x for v in verts]
ys = [v.y for v in verts]
span_x = (max(xs) - min(xs)) or 1e-6
span_y = (max(ys) - min(ys)) or 1e-6
pad = 2.0
avail_w = max(1.0, canvas.dot_w - 1 - 2 * pad)
avail_h = max(1.0, canvas.dot_h - 1 - 2 * pad)
scale = min(avail_w / span_x, avail_h / span_y)
origin_x = pad + (avail_w - span_x * scale) / 2
origin_y = pad + (avail_h - span_y * scale) / 2
min_x, max_y = min(xs), max(ys)
# Flip y so north points up: the northernmost point lands at the top dot row.
ring = [(origin_x + (v.x - min_x) * scale, origin_y + (max_y - v.y) * scale) for v in verts]
closed = ring + ring[:1]
# One flat tone for the enclosed side and its loop: even-odd shades only the odd
# (enclosed) region — a self-crossing walk's crossing stays unshaded — and nothing
# is drawn darker than anything else, so there is no dimmer sub-region.
canvas.fill_polygon([closed], _AREA_TONE, priority=0)
canvas.draw_line(closed, _AREA_TONE, priority=0)
# Hops first, then our own node last, so the star always wins its cell — a hop that
# projects onto the same cell can never hide us.
for v, (dx, dy) in zip(verts, ring, strict=True):
if not v.is_self:
canvas.marker(int(round(dx)), int(round(dy)), v.glyph, v.color)
for v, (dx, dy) in zip(verts, ring, strict=True):
if v.is_self:
canvas.marker(int(round(dx)), int(round(dy)), v.glyph, v.color)
return canvas.to_ansi_lines()
def render_body(self, width: int) -> list[str]:
"""Stats lanes, the route graph, the full route, provenance, then the actions."""
# Everything above the action rows — the stat lanes, the area drawing, the
# route graph and the provenance — is a pure function of the frozen record
# and the width, so it is composed once per width; only the cursor-bearing
# action rows below re-render per repaint.
cached = self._upper_cache
if cached is not None and cached[0] == width:
lines = list(cached[1])
else:
lines = self._upper_lines(width)
self._upper_cache = (width, list(lines))
lines.append("")
self._cursor = None
for i, key in enumerate(self._actions):
selected = i == self._index
row = Text("❯ " if selected else " ", style="cursor" if selected else "")
if key == "trace":
row.append_text(
marked_label("👣", "Trace this path — reopen in Trace path", "accent")
)
else:
row.append_text(marked_label("🗑", "Delete record…", "err"))
if selected:
row.style = "cursor"
self._cursor = len(lines)
row.no_wrap = True
row.truncate(width, overflow="ellipsis")
lines.append(render_to_ansi(row, width))
self._scroll_total = max(1, len(lines))
return lines
def _upper_lines(self, width: int) -> list[str]:
"""Compose the record card above the action rows (memoized per width)."""
record = self._record
lines: list[str] = []
lines.extend(self._compose_stats(self._stat_lanes(), width))
lines.append("")
lines.extend(self._graph_lines(width))
caption = Text("you → … → you · labels = hash byte", style="faint")
lines.append(render_to_ansi(caption, width, no_wrap=True))
lines.append(render_to_ansi(node_type_legend(), width, no_wrap=True))
lines.append("")
# The walk itself, on THE path widget, across the card's whole width. No ``route``
# label lane: the line under a route graph captioned "you → … → you" is the route,
# and the twelve cells a label would take are hops the reader came here for. It wraps
# at hop boundaries (never mid-name, never mid-chip) rather than hanging under a lane.
# Our two ends stand on the app-wide ★ rather than repeating our name and key — the
# graph above already marks us with the same star at both ends, and the legend under
# it reads that star back as "you" — kept in the ``you`` white, since nothing about a
# walk already made is ours to compose.
route = path_line(
[None, *record.route, None],
self._resolve,
prefix_bytes=record.width_bytes,
self_name=self._device_label,
show_hash=True,
hash_bytes=record.width_bytes,
device_hash=self._device_hash,
bare_self=True,
dim_self=False,
)
lines.extend(render_to_ansi(line, width, no_wrap=True) for line in route.wrapped(width))
spec = Text(record.spec, style="brand")
spec.append(f" ({record.width_bytes}-byte hops)", style="muted")
lines.extend(render_hanging(self._label("spec"), spec, width, indent=_LABEL_W))
stamp = record.discovered_at.astimezone().strftime("%b %d %Y %H:%M")
when = Text(stamp)
when.append(f" · MeshTerm {record.app_version}", style="muted")
lines.append(render_to_ansi(self._lane("recorded", when), width, no_wrap=True))
return lines
async def open_records(ctx: AppContext) -> dict:
"""Open the Trophy case browser and run it until dismissed.
Wires the browser to the database and the observed contacts: records come straight
from :meth:`Repository.discoveries`, and hop hashes resolve to friendly names
through the same resolver the trace screens use. *Trace this path* hands off to the
live Trace path screen with the record's route prefilled; when that screen closes,
this flow returns (unwinding to the main menu) instead of reopening the browser.
Args:
ctx: The shared application context (must be running the interactive TUI).
Returns:
A summary dict for the tool's run row (the record count shown).
Raises:
RuntimeError: If called outside the interactive menu.
"""
from .surface import TuiUi
from .trace_screen import open_trace_path
from .tui import CANCEL, Choice, SelectScreen, Separator
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu caller
raise RuntimeError("the Trophy case screen is only available in the menu")
session = ctx.ui.session
contacts = await ctx.devstate.contacts()
self_info = await ctx.devstate.self_info()
resolve = trace_runner.make_node_resolver(contacts, ctx.repo.node_names())
device_label = str(self_info.get("name") or "us")
device_hash = str(self_info.get("public_key") or "") or None
def _as_float(value) -> float | None: # noqa: ANN001
try:
return float(value)
except (TypeError, ValueError):
return None
lat, lon = _as_float(self_info.get("adv_lat")), _as_float(self_info.get("adv_lon"))
self_pos = (lat, lon) if lat is not None and lon is not None and (lat or lon) else None
# Per-target trace outcomes, for a record's reliability (the success rate of traces to
# its far node). Read once here; the far node is matched against these keys per record.
target_counts = ctx.repo.target_trace_counts()
# Node positions and types for a record's area drawing, gathered live the same way the
# trace tools gather them to score a walk: adverts we've heard, contacts over them. A
# record stores canonical ids, so match those the resolver's way (a prefix either side).
node_entries: list[tuple[str, tuple[float, float] | None, int | None]] = []
for heard in ctx.repo.heard_nodes():
if not heard.node:
continue
pos = (heard.lat, heard.lon) if heard.has_location else None
node_entries.append((heard.node.lower().removeprefix("0x"), pos, heard.node_type))
for contact in contacts:
ident = (contact.public_key or contact.key_prefix or "").lower().removeprefix("0x")
if not ident:
continue
pos = (
(contact.lat, contact.lon)
if (contact.has_location and (contact.lat or contact.lon))
else None
)
node_entries.append((ident, pos, contact.node_type))
# Memoized: the boards ask for the same route nodes over and over (every hop of
# every record, records sharing hops), and the entry list is fixed for this open —
# so each distinct id pays the prefix scan once.
geo_memo: dict[str, tuple[tuple[float, float] | None, int | None]] = {}
def node_geo(node_id: str) -> tuple[tuple[float, float] | None, int | None]:
"""A route node's best-known position and type across the heard/contact entries."""
cached = geo_memo.get(node_id)
if cached is not None:
return cached
needle = node_id.lower().removeprefix("0x")
pos: tuple[float, float] | None = None
ntype: int | None = None
for ident, epos, etype in node_entries:
if not (ident.startswith(needle) or needle.startswith(ident)):
continue
if pos is None:
pos = epos
if ntype is None:
ntype = etype
if pos is not None and ntype is not None:
break
geo_memo[node_id] = (pos, ntype)
return pos, ntype
def walk_drawing(
record: DiscoveredPath,
) -> tuple[str | None, str | None, list[WalkVertex] | None]:
"""The farthest node (name and id) and the walk's projected polygon.
Mirrors the scoring geometry (see :mod:`~meshterm.services.records`): our node at
the plane's origin, every positioned hop projected onto the same local km plane in
walk order, the farthest hop named. That farthest hop is the walk's natural target,
so its id is handed back for the reliability lookup. A polygon needs three points —
us plus two positioned hops — so a sparser walk yields no drawing but still names
its far point.
"""
if self_pos is None:
return None, None, None
glyph, color = self_marker()
verts = [WalkVertex(0.0, 0.0, glyph, color, True)]
far_label: str | None = None
far_id: str | None = None
far_dist = -1.0
for node_id in record.route:
pos, ntype = node_geo(node_id)
if pos is None:
continue
east, north = _local_xy(self_pos, pos)
# The node-type glyph in the node's *name* hue — the pin reads as that mesh
# name, the way the route line and graph labels colour it; an unnamed hop keeps
# the neutral shape tone so it never masquerades as a coloured name.
name = resolve(node_id)
hop_glyph = node_marker(ntype)[0]
hop_color = name_rgb(name, node_id) if name and name != node_id else _AREA_TONE
verts.append(WalkVertex(east, north, hop_glyph, hop_color, False))
dist = haversine_km(self_pos[0], self_pos[1], pos[0], pos[1])
if dist > far_dist:
far_dist = dist
far_id = node_id
far_label = name if name and name != node_id else None
return far_label, far_id, (verts if len(verts) >= 3 else None)
def walk_reliability(
far_id: str | None, far_label: str | None
) -> tuple[float, int, int] | None:
"""A record's observed reliability: the success rate of traces to its far node.
A walk's route can't be counted from history — a timed-out trace records no path —
but its farthest node *is* the target a boomerang was aimed at, and every trace
carries its target, failures included. So the far node's success rate (matched
against the filed targets by name or hex hash) is an honest delivery figure. Thin
by nature (the count is shown alongside); ``None`` when the node was never a target.
"""
if not far_id:
return None
needle = far_id.lower().removeprefix("0x")
wanted = far_label.lower() if far_label else None
ok = total = 0
for target, (t_ok, t_n) in target_counts.items():
key = target.lower().removeprefix("0x")
is_hex = bool(key) and all(c in "0123456789abcdef" for c in key)
if (wanted is not None and key == wanted) or (
is_hex and (key.startswith(needle) or needle.startswith(key))
):
ok += t_ok
total += t_n
return (ok / total, ok, total) if total else None
def ranked(category: Category) -> list[DiscoveredPath]:
"""One discipline's records, ranked best-first across every hash width."""
rows = ctx.repo.discoveries(category.id)
rows.sort(key=lambda r: r.score, reverse=not category.ascending)
return rows
def describe(category: Category) -> list:
"""The heading and its word-wrapped description as non-selectable rows."""
rows: list = [section_heading(f"{category.icon} {category.title}")]
desc = category.description
if category.needs_positions and self_pos is None:
desc += " — needs your location (set it in Config) to score"
for line in textwrap.wrap(desc, _DESC_WRAP):
rows.append(Separator(f" {line}", style="muted"))
return rows
def scored(category: Category, record: DiscoveredPath) -> str:
"""A record's score in its discipline's unit, bounded where the walk was partial."""
score = category.format_score(record.score)
if category.id == "long_haul" and not record.stats.get("km_complete", True):
score = "≥ " + score
return score
def browser_lanes(
rank: int,
category: Category,
record: DiscoveredPath,
*,
show_width: bool,
score_w: int,
) -> Text:
"""A record row's fixed lanes: rank, date, score, and the width when it disambiguates.
Held to what they actually say — the day the record was set (the dialog carries the
time), and a score lane fitted to the widest score on *this* board rather than a fixed
twelve — because every cell they don't spend is a hop the route gets to show. Composed
without a width, since nothing here fits itself to the terminal: this block is the
same on every platform and at every size, which is also what makes it the row's
h-scroll anchor (see :func:`browser_row`).
"""
row = Text(f"#{rank} ", style="muted")
row.append(record.discovered_at.astimezone().strftime("%b %d"), style="muted")
row.append(" ")
row.append(fit_cells(scored(category, record), score_w), style="accent")
if show_width:
row.append(f" {record.width_bytes} B", style="muted")
row.append(" ")
return row
def browser_row(lanes: Text, record: DiscoveredPath) -> Text:
"""One record row: its fixed lanes (:func:`browser_lanes`), then the whole walk.
The walk draws on THE path widget with both ends bare
(:data:`~meshterm.ui.pathline.SELF_GLYPH`): a record is a boomerang by construction,
so naming ourselves twice per row would cost more cells than the whole score lane and
tell the reader what every other row already told them. Those stars keep the ``you``
white (``dim_self=False``) — the fade means "not yours to compose", and nothing on a
board of walks already made is being composed.
The line is composed at its **natural** length and handed over whole, so every row is
cut the same way the highlighted one is: anchored on its first hop and cracked at the
lane's edge (:func:`~meshterm.ui.pathline.cut_to`, applied by the list). It is
deliberately *not* middle-elided to the width. That rescue exists to save a route's
two endpoints from a right truncation — but on this board both endpoints are the
same ``★`` on every row, by construction, so the ``⋯`` would spend three cells and a
hop to arrive back at the one thing the reader already knows. What differs from row to
row is the walk's front, and every cell goes to it.
A row that outruns the lane is read by *sliding* it: the highlight keeps the same line
and h-scrolls under ←→ from the lanes' own width (``Choice.hscroll_from``), so the walk
is the only thing on the row that moves — sliding rank and score off to the left would
cost the reader their place in the board and buy back cells the walk was already going
to be given. Unselected and selected therefore differ in exactly one thing, the shift.
"""
row = lanes.copy()
walk = path_line(
[None, *record.route, None],
resolve,
prefix_bytes=record.width_bytes,
hash_bytes=record.width_bytes,
self_name=device_label,
bare_self=True,
dim_self=False,
)
row.append_text(walk.text())
return row
async def delete_category_flow() -> None:
"""Pick a discipline, confirm, and delete its records (every width)."""
rows: list = []
for category in CATEGORIES:
count = len(ctx.repo.discoveries(category.id))
rows.append(
Choice(
title=Text.assemble(
(f"{category.icon} {category.title} ", ""),
(f"{count} record{'s' if count != 1 else ''}, all widths", "muted"),
),
value=category.id,
)
)
picked = await session.run_screen(
SelectScreen(
"Delete a discipline's records",
rows,
footer_hint="↑↓ move · Enter select · Esc back",
filterable=False,
)
)
if picked is CANCEL or picked is None: # Esc
return None
category = CATEGORY_BY_ID[str(picked)]
count = len(ctx.repo.discoveries(category.id))
if await session.typed_confirm(
f"This deletes all {count} {category.title} "
f"record{'s' if count != 1 else ''} — every hash width. "
"They can only be re-earned by walking them again.",
"delete",
title="Delete discipline records",
):
ctx.repo.delete_discoveries(category.id)
while True:
items: list = []
for category in CATEGORIES:
items.extend(describe(category))
board = ranked(category)
if not board:
items.append(Separator(" no records yet", style="muted"))
show_width = len({r.width_bytes for r in board}) > 1
# The score lane is this board's own widest score — "3 nodes" and "+6.0 dB"
# measure differently, and a lane sized for the worst case everywhere would
# spend the difference on padding in front of every route.
score_w = max((cell_len(scored(category, r)) for r in board), default=0)
for rank, record in enumerate(board, start=1):
lanes = browser_lanes(
rank,
category,
record,
show_width=show_width,
score_w=score_w,
)
items.append(
Choice(
# A finished line, not a width-aware callable: the row fits itself to
# nothing, so the list cuts every row — highlighted or not — the one way
# (see browser_row). Records are frozen, so it is composed once per open
# rather than per repaint.
title=browser_row(lanes, record),
value=("open", category, rank, record),
# ←→ slide the walk alone; the lanes in front of it hold (browser_row).
hscroll_from=lanes.cell_len,
)
)
total = len(ctx.repo.discoveries())
if total:
items.append(Separator(" "))
items.append(
Choice(
title=marked_label("🗑", "Delete a discipline's records…", "err"),
value=("del_cat", None, 0, None),
)
)
items.append(
Choice(
title=marked_label("🗑", "Delete all records…", "err"),
value=("del_all", None, 0, None),
)
)
browser = SelectScreen(
"Trophy case",
items,
footer_hint="↑↓ move · Enter open · Esc back",
hscroll=True, # a long walk slides under ←→ instead of dying at the fold
)
# The browser stays pushed for the whole visit, so every dialog that belongs *over*
# the trophy case — the discipline picker, the delete confirms, a record's floating
# story — already has it drawn full-frame behind them, and the cursor is still on the
# record just read when they close. The loop only leaves (and the list only rebuilds,
# losing that place) when the record set itself has changed under it.
async with session.stay(browser) as visit:
while True:
picked = await visit.result()
if picked is CANCEL or picked is None: # Esc
return {"records": total}
verb = picked[0]
if verb == "del_cat":
await delete_category_flow()
elif verb == "del_all":
if total and await session.typed_confirm(
f"This deletes all {total} records — every discipline, every width. "
"They can only be re-earned by walking them again.",
"delete",
title="Delete all records",
):
ctx.repo.delete_discoveries()
else:
_verb, category, rank, record = picked
far_label, far_id, shape = walk_drawing(record)
action = await session.run_screen(
RecordDialog(
record,
category,
rank,
resolve=resolve,
device_label=device_label,
device_hash=device_hash,
far_label=far_label,
far_id=far_id,
shape=shape,
reliability=walk_reliability(far_id, far_label),
type_of=lambda node_id: node_geo(node_id)[1],
)
)
if action == "trace":
# Walking a record's path opens the Trace screen *above* the trophy
# case, like any other sub-view: Esc from the trace is one pop back
# onto the record it was armed from, and ^W is what leaves the whole
# excursion. (It used to flatten the stack first and land the reader
# on the main menu, because there was no key that could climb out of
# a deep stack in one press. Now there is.)
await open_trace_path(ctx, spec=record.spec)
elif action == "delete":
sure = await ctx.ui.dialog(
f"Delete this {category.title} record?",
[("Cancel", False), ("Delete", True)],
title="Delete record",
default=1,
destructive=True,
)
if sure:
ctx.repo.delete_discovery(record.id)
# A trace can set a record and any of the deletes can drop several, so the
# board is rebuilt only when the record count actually moved — the one thing
# worth losing the cursor over.
if len(ctx.repo.discoveries()) != total:
break