Files
MeshTerm/meshterm/ui/widgets.py
T
Jean-Pierre MartineauandClaude Opus 4.8 dfd7d2332d Battery gauge: a braille fuel gauge pinned to the header's right edge
Add a one-cell braille battery indicator to the status bar, shown only when
the companion reports a pack. The cell fills bottom-to-top in four tiers —
green healthy, orange at a quarter, red near empty — with the true percent
beside it; it blinks red↔dim when critically low and sweeps a filling
animation while charging. A session-long BatteryService polls the pack,
maps millivolts to a state-of-charge estimate off a single-cell LiPo curve,
and infers charging from a rising terminal voltage (firmware exposes no
charging flag). The header pins the gauge flush right and the activity
pulse cedes exactly the cells it needs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:12:43 -04:00

929 lines
38 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.
"""Reusable Rich widgets: banner, status pill, trace tables, and progress bars."""
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Callable, Optional, Sequence
from rich import box
from rich.console import Console, Group
from rich.panel import Panel
from rich.progress import (
BarColumn,
MofNCompleteColumn,
Progress,
SpinnerColumn,
TextColumn,
TimeElapsedColumn,
)
from rich.table import Table
from rich.text import Text
from .. import __version__
from ..core.channels import is_name_derived, is_public_channel, is_public_name
from ..core.models import (
LOCAL_DEVICE_LABEL,
NODE_TYPE_CHAT,
NODE_TYPE_LABELS,
NODE_TYPE_REPEATER,
NODE_TYPE_ROOM,
NODE_TYPE_SENSOR,
Contact,
HopAggregate,
TraceResult,
TraceStats,
TxOptResult,
utcnow,
)
from .map_render import _NODE, _REPEATER, _SELF
from .theme import name_style, snr_style
if TYPE_CHECKING:
from ..core.discovery import DiscoveredDevice
#: Maps a hop's raw key-prefix hash to a display label (a contact name when known).
NodeResolver = Callable[[Optional[str]], Optional[str]]
def channel_glyph(name: str, secret: Optional[bytes]) -> str:
"""The one-character openness marker for a channel, shared across channel-facing screens.
``#`` marks a name-derived (``#``-style) channel, ``🌐`` a fixed-key well-known public
channel (e.g. the firmware default ``Public``), and ``🔒`` a private one. Every glyph is a
single double-width cell, so callers can prefix rows with ``"{glyph} "`` without disturbing
column alignment. When the secret is unknown, the name alone is used to guess.
Args:
name: The channel name.
secret: The channel's 16-byte secret, or ``None`` when only the name is known.
Returns:
A single-character glyph.
"""
if secret is None:
return "#" if is_public_name(name) else "🔒"
if is_name_derived(name, secret):
return "#"
if is_public_channel(name, secret):
return "🌐"
return "🔒"
def _identity(label: Optional[str]) -> Optional[str]:
"""Default node resolver: leave labels untouched."""
return label
def banner(
profile: Optional[str], mock: bool, selected_device: Optional["DiscoveredDevice"] = None
) -> Panel:
"""Build the application header panel.
Args:
profile: Active device profile name, if any.
mock: Whether the simulator is in use.
selected_device: The discovered device chosen for this session, if any, shown
when no named profile is in use.
Returns:
A Rich :class:`Panel` showing the app name, version, and connection target.
"""
if mock:
target = "[warn]simulator[/warn]"
elif profile:
target = profile
elif selected_device is not None:
target = selected_device.label
else:
target = "[muted]no device[/muted]"
body = Text.from_markup(
f"[brand]MeshTerm[/brand] [muted]v{__version__}[/muted]\n"
f"[muted]device:[/muted] {target}"
)
return Panel(body, border_style="accent", expand=False, title="[accent]Mesh[/accent]")
def make_progress(console: Console) -> Progress:
"""Create a themed progress bar for trace sweeps.
Args:
console: The console to render into.
Returns:
A configured :class:`rich.progress.Progress` (use as a context manager).
"""
return Progress(
SpinnerColumn(style="accent"),
TextColumn("[accent]{task.description}"),
BarColumn(complete_style="brand", finished_style="ok"),
MofNCompleteColumn(),
TimeElapsedColumn(),
console=console,
transient=False,
)
def _link_text(
origin: Optional[str],
destination: Optional[str],
device_label: str,
resolve: NodeResolver = _identity,
hash_bytes: Optional[int] = None,
device_hash: Optional[str] = None,
) -> Text:
"""Render an ``origin -> destination`` link through THE path widget.
A two-node :func:`path_text` in the trace presentation: a known node as
``name (hash)``, an unknown one as its bare hash, our own device as its label
(plus ``device_hash`` when supplied). Hashes are truncated to ``hash_bytes`` so
they match how the trace command addressed each node.
Args:
origin: The transmitting node (``None`` = our device).
destination: The receiving node (``None`` = our device).
device_label: The label used for our own device.
resolve: Maps a raw hop hash to a friendly name when the node is known.
hash_bytes: Path-hash width (bytes) to truncate shown hashes to.
device_hash: Our own device's key/hash, shown alongside its label when known.
Returns:
A :class:`Text` like ``us (a1) → Alice (3d)`` with the arrow muted.
"""
ends = [
None if not node or node == device_label else node
for node in (origin, destination)
]
return path_text(
ends, resolve, prefix_bytes=hash_bytes or 8, self_name=device_label,
show_hash=True, hash_bytes=hash_bytes, device_hash=device_hash,
)
def traces_table(
traces: list[TraceResult],
device_label: str = LOCAL_DEVICE_LABEL,
resolve: NodeResolver = _identity,
device_hash: Optional[str] = None,
) -> Table:
"""Render every trace's per-hop SNR side by side, one column per trace.
Hops are framed as ``origin -> destination`` links (the first originates at our
device, the last returns to it) and aligned by position across traces, so each
column is one trace's SNR readings down the shared path. Each node is annotated with
its hash at the command's path-hash width. A trailing ``min`` row shows each trace's
bottleneck — the per-trace values the run's *median min SNR* summarizes. Traces that
never replied appear as a ``✗`` column.
Args:
traces: The individual traces to display, in run order.
device_label: Name to show for our own device at the path's endpoints.
resolve: Maps a raw hop hash to a friendly contact name when known.
device_hash: Our own device's key/hash, annotated onto the path's endpoints.
Returns:
A Rich :class:`Table` with a column per trace.
"""
target = traces[0].target if traces else ""
# All traces in a run share the command's path-hash width; take it from the first
# successful one so node hashes render at the width they were addressed.
hash_bytes = next((t.path_hash_bytes for t in traces if t.success), None)
table = Table(title=f"Trace → {target}", border_style="muted", expand=False)
table.add_column("HOP", justify="right", style="muted")
table.add_column("FROM → TO")
for i in range(1, len(traces) + 1):
table.add_column(f"#{i}", justify="right")
# Index each trace's edges by hop position so columns line up even when traces
# take different-length paths (a missing hop shows as a muted dash).
edges_by_trace = [
{e.index: e for e in t.edges(device_label)} if t.success else {} for t in traces
]
indices = sorted({idx for edges in edges_by_trace for idx in edges})
for idx in indices:
link = next(
(
_link_text(
edges[idx].origin, edges[idx].destination, device_label, resolve,
hash_bytes, device_hash,
)
for edges in edges_by_trace
if idx in edges
),
Text("—", style="muted"),
)
row: list[Text] = [Text(str(idx)), link]
for edges in edges_by_trace:
edge = edges.get(idx)
if edge is None:
row.append(Text("—", style="muted"))
else:
row.append(Text(f"{edge.snr:+.1f}", style=snr_style(edge.snr)))
table.add_row(*row)
# Bottleneck per trace: the weakest link the run's median min SNR is taken over.
min_row: list[Text] = [Text("min", style="muted"), Text("bottleneck", style="muted")]
for trace in traces:
if not trace.success:
min_row.append(Text("✗", style="err"))
elif trace.min_snr is not None:
min_row.append(Text(f"{trace.min_snr:+.1f}", style=snr_style(trace.min_snr)))
else:
min_row.append(Text("—", style="muted"))
table.add_section()
table.add_row(*min_row)
return table
def path_text(
hops: Sequence[Optional[str]],
resolve: NodeResolver = _identity,
*,
prefix_bytes: int = 0,
self_name: Optional[str] = None,
empty: str = "direct",
show_hash: bool = False,
hash_bytes: Optional[int] = None,
device_hash: Optional[str] = None,
dim_from: Optional[int] = None,
) -> Text:
"""Render a hop sequence compactly on one line — THE path widget.
The one way MeshTerm shows a walked, relayed, or planned hop sequence, wherever one
appears: a packet's ``via`` row, a message's delivery paths, a feed note, a trace's
walked route or planned spec. Each hop renders as its resolved name — coloured in
the app-wide per-name hue, our own node pure white — or, unnamed, as its hash
through :func:`highlighted_hash` (the addressed prefix lit, the rest muted; colour
stays the "this is a name" signal). Hops are joined by muted ``→`` arrows and, by
default, no hash is repeated after a name, so the compact form survives a 72-column
row. The trace-flavoured options: ``show_hash`` annotates each named hop with the
hash it is addressed by (``Alice (3d63)``), a ``None`` hop is our own device at a
route's endpoints, and ``dim_from`` fades the tail a caller wants read as automatic
(a boomerang's mirrored return leg). An empty path reads as ``empty``.
Args:
hops: The hops in propagation order — hex hashes, with ``None`` marking our
own device (empty strings are skipped; ``dim_from`` counts rendered hops).
resolve: Maps a hop hash to a friendly name when known.
prefix_bytes: Path-hash width to light in unnamed hops' hashes (0 = none).
self_name: Our own node's name — drawn in the white ``you`` style when a
resolved name matches it, and naming any ``None`` device hop.
empty: The muted text shown when there are no hops (e.g. ``"direct"``).
show_hash: Annotate named hops (and, with ``device_hash``, our device) with
their hash in parentheses — the trace presentation.
hash_bytes: Truncate shown/annotated hashes to this byte width (the width the
hops were addressed at); ``None`` shows them whole.
device_hash: Our own device's key, annotated onto ``None`` hops when
``show_hash`` is on.
dim_from: Render hops at/after this index — and the arrows into them — faint
(a planned route's return leg); ``None`` dims nothing.
Returns:
A one-line :class:`Text`. Space tighter than the path is the caller's call,
per surface: ellipsize (``no_wrap``), wrap under a hanging indent, or crop
with horizontal scrolling.
"""
shown = [h for h in hops if h is None or h]
if not shown:
return Text(empty, style="muted")
text = Text()
for i, hop in enumerate(shown):
dim = dim_from is not None and i >= dim_from
if i:
text.append(" → ", style="faint" if dim else "muted")
text.append_text(
_path_node(
hop, resolve, prefix_bytes=prefix_bytes, self_name=self_name,
show_hash=show_hash, hash_bytes=hash_bytes, device_hash=device_hash,
dim=dim,
)
)
return text
def _path_node(
hop: Optional[str],
resolve: NodeResolver,
*,
prefix_bytes: int,
self_name: Optional[str],
show_hash: bool,
hash_bytes: Optional[int],
device_hash: Optional[str],
dim: bool,
) -> Text:
"""One node of :func:`path_text` (see there for the rendering rules)."""
note_style = "faint" if dim else "muted"
if hop is None:
text = Text(self_name or LOCAL_DEVICE_LABEL, style="faint" if dim else "you")
annotated = _shorten_hash(device_hash, hash_bytes) if device_hash else ""
if show_hash and annotated:
text.append(f" ({annotated})", style=note_style)
return text
named = resolve(hop)
if named and named != hop:
if dim:
style = "faint"
elif self_name and named == self_name:
style = "you"
else:
style = name_style(named)
text = Text(named, style=style)
if show_hash:
text.append(f" ({_shorten_hash(hop, hash_bytes)})", style=note_style)
return text
shown = _shorten_hash(hop, hash_bytes)
if dim:
return Text(shown, style="faint")
return highlighted_hash(shown, prefix_bytes)
def highlighted_hash(value: str, prefix_bytes: int, width: Optional[int] = None) -> Text:
"""Render a hex key with its leading path-hash prefix highlighted — THE hash widget.
The one way MeshTerm displays a hash, wherever one appears: the first
``prefix_bytes`` bytes are the slice other nodes address in a forced trace path
(the path-hash), shown in the brand colour with the remainder muted, so the
addressable prefix stands out within the otherwise full key. A ``width`` budget
shorter than the key ellipsizes it (the ``…`` takes the colour of the digit it
replaces, so a highlight wider than the budget still reads as one).
Args:
value: The key as hex, optionally ``0x``-prefixed and mixed-case.
prefix_bytes: Number of leading bytes the current path-hash mode addresses; ``0``
(or negative) leaves the whole key un-highlighted.
width: Display budget in cells; a longer key shows ``width - 1`` digits plus an
ellipsis, a shorter one is right-padded to the budget so lanes stay aligned.
``None`` shows the key whole, unpadded.
Returns:
A styled :class:`Text` of the key (exactly ``width`` cells when given).
"""
raw = value.lower().removeprefix("0x")
split = max(0, prefix_bytes) * 2
pad = 0
ellipsis = False
if width is not None and len(raw) > width:
raw, ellipsis = raw[: max(0, width - 1)], True
elif width is not None:
pad = width - len(raw)
text = Text()
text.append(raw[:split], style="brand")
text.append(raw[split:], style="muted")
if ellipsis:
text.append("…", style="brand" if len(raw) < split else "muted")
text.append(" " * pad)
return text
def _shorten_hash(value: str, hash_bytes: Optional[int]) -> str:
"""Return ``value`` as bare hex truncated to ``hash_bytes`` bytes.
Args:
value: A hex hash, optionally ``0x``-prefixed and mixed-case.
hash_bytes: Width in bytes to truncate to; the full value when falsy/unknown.
Returns:
Lowercase hex with no ``0x`` prefix, at most ``hash_bytes`` bytes wide.
"""
raw = value.lower().removeprefix("0x")
return raw[: hash_bytes * 2] if hash_bytes else raw
def _route_text(
result: TraceResult,
device_label: str = LOCAL_DEVICE_LABEL,
resolve: NodeResolver = _identity,
device_hash: Optional[str] = None,
) -> Text:
"""Render a trace's walked route through THE path widget.
Shows the path the trace actually walked — the forced path, or the route the
device resolved when auto-routing — as a :func:`path_text` in the trace
presentation: each node annotated by its hash at the command's path-hash width,
our own device bracketing both ends, e.g.
``Me (a1b2) → Alice (3d63) → Bob (f2a1) → Me (a1b2)``.
Args:
result: The trace whose route to display.
device_label: Name to show for our own device at the path's endpoints.
resolve: Maps a raw hop hash to a friendly contact name when known.
device_hash: Our own device's key/hash, annotated onto its endpoints when known.
Returns:
A :class:`Text` with the node sequence, or a muted note when no hops exist.
"""
edges = result.edges(device_label)
if not edges:
return Text("no hops recorded", style="muted")
hash_bytes = result.path_hash_bytes
nodes = [edges[0].origin] + [edge.destination for edge in edges]
hops = [None if not node or node == device_label else node for node in nodes]
return path_text(
hops, resolve, prefix_bytes=hash_bytes or 8, self_name=device_label,
show_hash=True, hash_bytes=hash_bytes, device_hash=device_hash,
)
def _hop_medians_table(
hop_snrs: list[HopAggregate],
device_label: str,
resolve: NodeResolver = _identity,
hash_bytes: Optional[int] = None,
device_hash: Optional[str] = None,
) -> Table:
"""Render the per-hop median SNR aggregated across a run's traces.
Args:
hop_snrs: The per-hop aggregates to display.
device_label: Name to show for our own device at the path's endpoints.
resolve: Maps a raw hop hash to a friendly contact name when known.
hash_bytes: Path-hash width (bytes) to truncate shown node hashes to.
device_hash: Our own device's key/hash, annotated onto the path's endpoints.
Returns:
A compact Rich :class:`Table` of hop, link, and median SNR.
"""
table = Table(box=None, padding=(0, 1, 0, 0), expand=False)
table.add_column("HOP", justify="right", style="muted")
table.add_column("FROM → TO")
table.add_column("MEDIAN SNR", justify="right")
for agg in hop_snrs:
table.add_row(
str(agg.index),
_link_text(
agg.origin, agg.destination, device_label, resolve, hash_bytes, device_hash
),
Text(f"{agg.median_snr:+.1f} dB", style=snr_style(agg.median_snr)),
)
return table
def stats_panel(
stats: TraceStats,
device_label: str = LOCAL_DEVICE_LABEL,
resolve: NodeResolver = _identity,
route: Optional[TraceResult] = None,
device_hash: Optional[str] = None,
) -> Panel:
"""Summarize aggregated trace statistics in a panel.
Includes the median SNR for every hop along the path (not just the bottleneck), so
a weak link anywhere in the route is visible. When a representative ``route`` trace
is given, the path it actually walked is shown as a node sequence, e.g.
``Me → Alice → Bob → Me``.
Args:
stats: The aggregated statistics to display.
device_label: Name to show for our own device at the path's endpoints.
resolve: Maps a raw hop hash to a friendly contact name when known.
route: A representative trace whose walked route to display, if any.
device_hash: Our own device's key/hash, annotated onto the route endpoints.
Returns:
A Rich :class:`Panel` with the route, success rate, robust SNR/RTT, and
per-hop medians.
"""
snr = stats.median_min_snr
snr_text = Text(f"{snr:+.1f} dB", style=snr_style(snr)) if snr is not None else Text("n/a")
rtt = f"{stats.median_rtt_ms:.0f} ms" if stats.median_rtt_ms is not None else "n/a"
summary = Text.assemble(
("target ", "muted"), (f"{stats.target}\n", ""),
("success rate ", "muted"), (f"{stats.success_rate:.0%} "
f"({stats.successes}/{stats.samples})\n", ""),
("median min SNR ", "muted"), snr_text, ("\n", ""),
("median RTT ", "muted"), (rtt, ""),
)
sections: list[Text | Table] = []
if route is not None:
sections.append(Text("Route", style="accent"))
sections.append(_route_text(route, device_label, resolve, device_hash))
sections.append(Text()) # blank line before the stats block
sections.append(summary)
if stats.hop_snrs:
sections.append(Text("\nPer-hop medians", style="accent"))
hash_bytes = route.path_hash_bytes if route is not None else None
sections.append(
_hop_medians_table(stats.hop_snrs, device_label, resolve, hash_bytes, device_hash)
)
body: Text | Group = sections[0] if len(sections) == 1 else Group(*sections)
return Panel(body, title="[accent]Trace summary[/accent]", border_style="accent", expand=False)
# Node-type glyphs and their colours, consistent with the map's marker palette across the
# whole app (see ui.map_render): our own node is the yellow ``★``, plain nodes the loud pink
# ``●`` and repeaters the calmer violet ``▲``. The remaining types take map-safe hues that
# stay distinct from those — a white square for rooms (the house glyph read poorly) and an
# orange ringed dot for sensors. All are single-width BMP glyphs so columns stay aligned.
_ROOM_COLOR = "#ffffff"
_SENSOR_COLOR = "#fb923c"
_NODE_GLYPHS: dict[int, tuple[str, str]] = {
NODE_TYPE_REPEATER: (_REPEATER[0], _REPEATER[1]),
NODE_TYPE_ROOM: ("■", _ROOM_COLOR),
NODE_TYPE_SENSOR: ("◉", _SENSOR_COLOR),
NODE_TYPE_CHAT: (_NODE[0], _NODE[1]),
}
_DEFAULT_GLYPH: tuple[str, str] = (_NODE[0], _NODE[1])
# A heat-map gradient for a contact's name, hottest (most recently heard) to coldest: white
# → yellow → orange → red → grey. Each stop pairs an age anchor (log10 of seconds since heard)
# with an RGB colour; :func:`_recency_style` interpolates continuously between them, so the
# colour glides with recency rather than snapping between a handful of discrete shades.
_HEAT_STOPS: tuple[tuple[float, tuple[int, int, int]], ...] = (
(math.log10(300), (255, 255, 255)), # ≤5m — white (fresh)
(math.log10(3600), (250, 204, 21)), # ~1h — yellow
(math.log10(21600), (251, 146, 60)), # ~6h — orange
(math.log10(86400), (248, 113, 113)), # ~1d — red
(math.log10(604800), (148, 163, 184)), # ~1w — grey
(math.log10(2592000), (100, 116, 139)), # ~30d+ — cold slate
)
_RECENCY_NEVER = "#64748b" # never heard — the coldest slate
def _age_seconds(when: Optional[datetime]) -> Optional[float]:
"""Seconds since ``when`` (aware UTC), or ``None`` when unknown/naive."""
if when is None or getattr(when, "tzinfo", None) is None:
return None
return max(0.0, (utcnow() - when).total_seconds())
def _format_age(secs: Optional[float]) -> str:
"""A compact relative age — ``now``, ``5m``, ``3h``, ``2d``, ``4w`` — or ``never``."""
if secs is None:
return "never"
if secs < 60:
return "now"
if secs < 3600:
return f"{int(secs // 60)}m"
if secs < 86400:
return f"{int(secs // 3600)}h"
if secs < 604800:
return f"{int(secs // 86400)}d"
return f"{int(secs // 604800)}w"
def format_ago(secs: Optional[float]) -> str:
"""The relative-age *phrase* — ``now``, ``5m ago``, ``never`` — for running prose.
The canonical grammar for every "heard … (…)" and "delivered …" row: a fresh
sighting reads as bare ``now`` and an unknown one as bare ``never`` (neither takes
the suffix — "now ago" is nonsense), while any measured age reads ``5m ago``.
Callers embedding an age in a sentence or parenthetical use this;
:func:`_format_age` stays the bare column form for aligned age lanes.
"""
age = _format_age(secs)
return age if age in ("now", "never") else f"{age} ago"
def _recency_style(secs: Optional[float]) -> str:
"""The heat-map name colour for a contact last heard ``secs`` ago (hotter = more recent).
Interpolates the RGB channels between the two :data:`_HEAT_STOPS` bracketing ``secs`` (in
log-age space), clamping to white below the first stop and cold slate above the last.
"""
if secs is None:
return _RECENCY_NEVER
x = math.log10(max(secs, 0.0) + 1.0)
if x <= _HEAT_STOPS[0][0]:
r, g, b = _HEAT_STOPS[0][1]
elif x >= _HEAT_STOPS[-1][0]:
r, g, b = _HEAT_STOPS[-1][1]
else:
(x0, c0), (x1, c1) = next(
(lo, hi) for lo, hi in zip(_HEAT_STOPS, _HEAT_STOPS[1:]) if lo[0] <= x <= hi[0]
)
f = (x - x0) / (x1 - x0)
r, g, b = (round(a + (bb - a) * f) for a, bb in zip(c0, c1))
return f"#{r:02x}{g:02x}{b:02x}"
def _key_id(value: str) -> str:
"""Normalise a key/prefix to the lowercased 12-hex id used to match heard nodes."""
return value.lower().removeprefix("0x")[:12]
def _contact_pkts(contact: Contact, counts: dict[str, int]) -> Optional[int]:
"""The overheard-packet tally for ``contact``, or ``None`` if never overheard."""
ident = contact.public_key or contact.key_prefix
return counts.get(_key_id(ident)) if ident else None
# The columns the node list can be sorted by, left-to-right, and the direction each opens on
# — name A→Z, most-recently-heard first, most packets first — chosen so a fresh sort shows
# the "interesting" end at the top.
_SORT_COLUMNS: tuple[str, ...] = ("name", "heard", "packets")
_SORT_OPENS_ASCENDING: dict[str, bool] = {"name": True, "heard": True, "packets": False}
@dataclass
class NodesSort:
"""Which column the node list is sorted by, and in which direction.
``column`` is one of :data:`_SORT_COLUMNS`; ``ascending`` sorts the column's underlying
metric low-to-high — name A→Z, *age* (so ascending = most recently heard first), packet
count low-to-high. The interactive screen mutates this in place as the user presses the
arrows.
"""
column: str = "name"
ascending: bool = True
@classmethod
def from_name(cls, name: str) -> "NodesSort":
"""Build a sort for ``name``, opening in that column's natural direction."""
column = name if name in _SORT_COLUMNS else "name"
return cls(column, _SORT_OPENS_ASCENDING[column])
def move(self, delta: int) -> None:
"""Step the active column ``delta`` places (wrapping), adopting its natural direction."""
index = (_SORT_COLUMNS.index(self.column) + delta) % len(_SORT_COLUMNS)
self.column = _SORT_COLUMNS[index]
self.ascending = _SORT_OPENS_ASCENDING[self.column]
def _ordered_contacts(
contacts: list[Contact], counts: dict[str, int], sort: NodesSort
) -> list[Contact]:
"""Contacts sorted per ``sort`` (our own node is pinned separately, above these).
The active column's metric drives the order (reversed for a descending sort); ties always
break by case-folded name *ascending*, so two nodes sharing a metric (e.g. the same
``heard`` age) keep a stable A→Z order instead of flipping with the primary direction.
Never-heard / never-overheard rows carry an extreme metric so they gather at the ascending
end.
"""
if sort.column == "heard":
# One clock snapshot for the whole sort: per-row utcnow() calls would skew two
# identical last_seen stamps apart by microseconds and defeat the name tie-break.
now = utcnow()
def metric(c: Contact) -> float:
if c.last_seen is None or getattr(c.last_seen, "tzinfo", None) is None:
return float("inf")
return max(0.0, (now - c.last_seen).total_seconds())
elif sort.column == "packets":
def metric(c: Contact) -> float:
return _contact_pkts(c, counts) or 0
else:
def metric(c: Contact) -> object:
return c.name.casefold()
# Name-ascending first, then a stable sort by the primary metric: equal-metric rows retain
# their A→Z order under both directions (reversing the whole list would flip the tiebreak).
ordered = sorted(contacts, key=lambda c: c.name.casefold())
ordered.sort(key=metric, reverse=not sort.ascending)
return ordered
def _sort_header(label: str, column: str, sort: NodesSort) -> str:
"""A column header: plain-muted, or cyan with a direction triangle when it's the sort key.
The active column's name and its triangle are lit cyan together (so the interactive
left/right selection is obvious) — ``▲`` for ascending, ``▼`` for descending. The column
reserves its width (see :func:`nodes_table`) so toggling the sort doesn't shift the row.
"""
if column != sort.column:
return label
triangle = "▲" if sort.ascending else "▼"
return f"[bold #22d3ee]{label} {triangle}[/]"
def _nodes_legend() -> Text:
"""A one-line glyph legend covering every node type (plus us), whatever's listed."""
legend = Text(" ") # a small indent to sit under the table body
legend.append(_SELF[0], style=_SELF[1])
legend.append(" you", style="muted")
for node_type in (NODE_TYPE_REPEATER, NODE_TYPE_CHAT, NODE_TYPE_ROOM, NODE_TYPE_SENSOR):
glyph, color = _NODE_GLYPHS[node_type]
legend.append(" ")
legend.append(glyph, style=color)
legend.append(f" {NODE_TYPE_LABELS[node_type]}", style="muted")
return legend
def nodes_table(
self_name: str,
self_key: str,
contacts: list[Contact],
prefix_bytes: int,
counts: dict[str, int],
sort: Optional[NodesSort] = None,
) -> Group:
"""List this node and its known contacts with recency, packets, type, key, and legend.
Our own node is the first row (``★``, name in the white ``you`` style); contacts follow
in ``sort`` order.
A per-type glyph marks each node in the app's shared colours, the name is coloured by how
recently it was last heard (brighter = fresher), and the full key is shown with its
path-hash prefix lit — chopped with an ellipsis only when the terminal is too narrow.
Args:
self_name: This node's advertised name.
self_key: This node's full public key (hex); blank renders as ``?``.
contacts: Known contacts, listed after our own node.
prefix_bytes: Path-hash width in bytes to highlight in every key.
counts: Overheard-packet counts keyed by lowercased 12-hex node id (from
monitoring); a contact with no entry shows ``—``.
sort: The active sort (column + direction); defaults to name-ascending. The sorted
column's header is lit cyan with an up/down direction triangle.
Returns:
A Rich :class:`Group` of the frameless table and its glyph legend.
"""
sort = sort if sort is not None else NodesSort()
# expand=True lets the key column (the only flexible one) soak up all spare width and be
# the sole column Rich squeezes when narrow — the fixed columns keep their natural size.
table = Table(
title=f"[accent]Nodes[/accent] [muted]· {len(contacts)} known[/muted]",
title_justify="left",
box=box.SIMPLE_HEAD,
show_edge=False,
pad_edge=False,
header_style="muted",
expand=True,
padding=(0, 2, 0, 0),
)
# Each sortable header reserves two extra columns for its " ▲" direction marker (via
# min_width = label + 2) so the same width holds whether or not it's the active sort —
# switching the sort never widens a column and shifts the rest of the row.
table.add_column("", no_wrap=True) # node-type glyph
table.add_column(_sort_header("NAME", "name", sort), no_wrap=True, min_width=6)
table.add_column(
_sort_header("HEARD", "heard", sort), justify="right", no_wrap=True, min_width=7
)
table.add_column(
_sort_header("PKTS", "packets", sort), justify="right", no_wrap=True, min_width=6
)
# The full key, chopped to an ellipsis by Rich only when the row won't otherwise fit.
table.add_column("KEY", no_wrap=True, overflow="ellipsis", ratio=1, min_width=10)
unknown = Text("?", style="muted")
table.add_row(
Text(_SELF[0], style=_SELF[1]),
# Our own name is the app-wide pure-white "you" style, never a palette hue.
Text.assemble((self_name, "you"), (" (you)", "muted")),
Text("—", style="faint"),
Text("—", style="faint"),
highlighted_hash(self_key, prefix_bytes) if self_key else unknown,
)
table.add_section()
for c in _ordered_contacts(contacts, counts, sort):
secs = _age_seconds(c.last_seen)
glyph, glyph_style = _NODE_GLYPHS.get(c.node_type, _DEFAULT_GLYPH)
pkts = _contact_pkts(c, counts)
table.add_row(
Text(glyph, style=glyph_style),
Text(c.name, style=_recency_style(secs)),
Text(_format_age(secs), style="muted"),
Text(str(pkts), style="muted") if pkts else Text("—", style="faint"),
highlighted_hash(c.public_key, prefix_bytes) if c.public_key else unknown,
)
return Group(table, Text(""), _nodes_legend())
def tx_opt_table(result: TxOptResult) -> Table:
"""Render every measured TX level of an optimization sweep, best row highlighted.
Args:
result: The optimization result to display.
Returns:
A Rich :class:`Table` of TX level, target SNR, success rate, and sample count.
"""
table = Table(
title=f"TX sweep · {result.admin_node} -> {result.target}",
border_style="muted",
expand=False,
)
table.add_column("TX", justify="right")
table.add_column("TARGET SNR", justify="right")
table.add_column("SUCCESS", justify="right")
table.add_column("TRACES", justify="right")
for lv in result.sorted_by_tx():
is_best = lv.tx_power == result.best_tx
marker = "[ok]★[/ok] " if is_best else " "
snr = lv.target_snr
snr_cell = Text(f"{snr:+.1f}", style=snr_style(snr)) if snr is not None else Text("—")
# Highlight anything short of a perfect success rate — reliability comes first.
rate = lv.success_rate
rate_style = "ok" if rate >= 1.0 else ("warn" if rate > 0 else "err")
rate_cell = Text(f"{rate:.0%}", style=rate_style)
tx_cell = f"{marker}{lv.tx_power}"
row_style = "ok" if is_best else None
table.add_row(
tx_cell, snr_cell, rate_cell, f"{lv.successes}/{lv.samples}", style=row_style
)
return table
def tx_opt_summary(result: TxOptResult) -> Panel:
"""Summarize a TX optimization outcome.
Args:
result: The optimization result to summarize.
Returns:
A Rich :class:`Panel` stating the chosen optimum and whether it was applied.
"""
snr = result.best_snr
snr_text = Text(f"{snr:+.1f} dB", style=snr_style(snr)) if snr is not None else Text("n/a")
applied = (
f"[ok]applied to {result.admin_node}[/ok]"
if result.applied
else "[muted]not applied[/muted]"
)
body = Text.assemble(
("tuning node ", "muted"), (f"{result.admin_node}\n", "brand"),
("target ", "muted"), (f"{result.target}\n", "brand"),
("optimal TX ", "muted"), (f"{result.best_tx}", "brand"), ("\n", ""),
("target SNR ", "muted"), snr_text, ("\n", ""),
("reliability ", "muted"), (f"{result.best_success_rate:.0%}\n", ""),
("previous TX ", "muted"),
(f"{result.original_tx if result.original_tx is not None else '?'}\n", ""),
("status ", "muted"), Text.from_markup(applied),
)
return Panel(body, title="[accent]TX optimization[/accent]", border_style="accent", expand=False)
# -- battery gauge --------------------------------------------------------------------------
#: Unicode braille pattern base; add an 8-bit dot mask (a 2×4 cell) to get the glyph.
_BRAILLE_BASE = 0x2800
#: Dot bit per (column, row) within a braille cell — the Unicode standard layout, the same
#: mapping the map canvas rasters with. Kept here so the one-cell battery glyph needn't reach
#: into the canvas module for two constants.
_BRAILLE_DOTS = ((0x01, 0x02, 0x04, 0x40), (0x08, 0x10, 0x20, 0x80))
#: Battery charge tiers: ``(percent floor, braille rows lit from the bottom, colour)``,
#: highest first. The glyph is one braille cell (2×4 dots) filled from its bottom row up, so
#: four coarse levels read as a shrinking fuel gauge: the whole cell above three-quarters,
#: six dots above half, four above a quarter, two below it — green while healthy, orange at a
#: quarter, red near empty (the app's ok/warn/err semantics as a gauge).
_BATTERY_TIERS: tuple[tuple[int, int, str], ...] = (
(75, 4, "batt.high"),
(50, 3, "batt.high"),
(25, 2, "batt.mid"),
(0, 1, "batt.low"),
)
#: At or below this charge the red glyph blinks with a dim frame — a can't-miss low warning.
_BATTERY_CRITICAL = 10
#: Frames in the charging sweep: empty → four fills → round again (bottom-to-full loop).
_CHARGE_FRAMES = 5
def _braille_fill(rows: int) -> str:
"""A single braille cell with its bottom ``rows`` (0–4) dot rows lit."""
rows = max(0, min(4, rows))
bits = 0
for row in range(4 - rows, 4):
bits |= _BRAILLE_DOTS[0][row] | _BRAILLE_DOTS[1][row]
return chr(_BRAILLE_BASE + bits)
def _battery_tier(percent: int) -> tuple[int, str]:
"""The ``(rows lit, colour)`` a charge level draws at (see :data:`_BATTERY_TIERS`)."""
for floor, rows, color in _BATTERY_TIERS:
if percent >= floor:
return rows, color
return 1, "batt.low"
def battery_cell(percent: int, *, charging: bool = False, frame: int = 0) -> Text:
"""The status-bar battery gauge: one braille cell coloured by charge, then its ``%``.
The braille cell fills from the bottom up in four coarse steps (see
:data:`_BATTERY_TIERS`), coloured green while healthy, orange at a quarter, red near
empty. Two live states animate off the caller's ``frame`` counter (advanced one step per
repaint tick), so the gauge moves without any per-frame plumbing:
* **Charging** overrides the fill: the cell sweeps empty-to-full on a loop, the charge
colour held, so a plugged-in pack visibly climbs — while the number beside it stays the
*true* charge, never the animation's.
* **Critically low** (≤ :data:`_BATTERY_CRITICAL`%, not charging) blinks the red cell to a
dim slate on alternate frames — an unmissable pulse in the corner.
Args:
percent: State of charge, 0–100 (clamped).
charging: Whether the pack is taking charge (drives the fill sweep).
frame: A monotonically advancing tick; only its phase is read, so any
steadily-incrementing integer animates the two live states.
Returns:
A Rich :class:`Text`: the coloured glyph, a space, and ``NN%`` in muted text.
"""
pct = max(0, min(100, int(percent)))
rows, color = _battery_tier(pct)
if charging:
# The charge colour is held; only the fill sweeps, so it reads as "filling", not
# as the charge itself jumping around.
rows = frame % _CHARGE_FRAMES
elif pct <= _BATTERY_CRITICAL and frame % 2:
color = "batt.dim"
out = Text(_braille_fill(rows), style=color)
out.append(f" {pct}%", style="muted")
return out