Braille meter and axis captions: two new shared chart widgets

meter() is the one horizontal proportion bar (two fill steps per cell,
full-height tally or slim-on-a-track gauge); the SNR bar now draws
through it. axis_caption() fills a timeline's caption with as many
intermediate marks as the width fits, instead of only its two ends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-07-11 18:11:42 -04:00
1 parent ffa834182b
commit f9411bfc9b
3 files changed
+134 -26

No files matched your search

+123 -1
View File
@@ -1,4 +1,4 @@
"""Scrolling braille bar charts: the one way MeshTerm draws a value-over-time row.
"""Braille charts: the one way MeshTerm draws a value-over-time row or a meter.
Every timeline in the app — the header's activity pulse, the channel manager's
per-channel sparklines, the dashboard's tall packet chart, the Time Machine's
@@ -22,6 +22,14 @@ spans anchored on the baseline, and each character cell takes the bar style when
any bar dot falls in it, the faint baseline style when only the zero line does,
and stays blank braille otherwise (blank braille, not a space, so the grid stays
monospace under fonts with slightly odd braille metrics).
Beyond timelines, the module owns the app's two other braille conventions:
* :func:`meter` — the single-value horizontal bar (the dashboard's traffic
tallies, the SNR quality bars), always packing two fill steps per cell so a
``width``-cell meter resolves ``2 × width`` levels.
* :func:`axis_caption` — the ``oldest → now`` line under a timeline, which
fills in intermediate marks whenever the chart is wide enough to fit them.
"""
from __future__ import annotations
@@ -152,6 +160,120 @@ def activity_sparkline(
return _assemble(bars, [float(c) for c in reversed(window)], 1, 0, style, "faint")[0]
#: The meter's fill glyphs, ``(full step, half step)``, by profile. A *full-height*
#: meter lights all four dot rows (``⣿`` both columns, ``⡇`` the left column alone),
#: reading as a solid tally bar. A *slim* meter lights only the middle two rows
#: (``⠶`` / ``⠆``), so the bar floats mid-cell and can sit over an unlit track of the
#: same glyph without turning into a solid block.
_METER_FULL = ("⣿", "⡇")
_METER_SLIM = ("⠶", "⠆")
def meter(
fraction: Optional[float],
width: int,
*,
style: str,
slim: bool = False,
track: Optional[str] = None,
) -> Text:
"""Render a single value as a horizontal braille meter, two fill steps per cell.
The one way MeshTerm draws a proportion as a bar. Both dot columns of every cell
are always exploited — a ``width``-cell meter resolves ``2 × width`` levels — and
any *reading* lights at least one half step, even one clamped to the floor of its
scale: a measured bottom is still a measurement, so it never vanishes (``None``
is how a truly empty meter is asked for). Two profiles cover the app's two cases:
* **full-height, no track** (the default): all four dot rows, unlit cells left
blank — a tally bar whose length *is* the reading (the dashboard's traffic
lanes).
* **slim, on a track** (``slim=True, track="track"``): only the middle two dot
rows, with the unlit remainder drawn in the same glyph dimmed to ``track`` — a
gauge whose reading fills in a visible background (the SNR quality bars). The
boundary half-step keeps the reading's colour, not the track's: it is still
part of what was measured.
Args:
fraction: The fill as a fraction of full scale, clamped to ``0 .. 1``;
``None`` draws an entirely unlit meter (just the track, if any).
width: The meter's full-scale span in character cells.
style: Style for the lit fill.
slim: Light only the middle two dot rows instead of all four.
track: Style for the unlit remainder, drawn in the full-step glyph; ``None``
pads with spaces instead, so the meter still occupies ``width`` cells.
Returns:
A :class:`Text` exactly ``width`` cells wide.
"""
full_glyph, half_glyph = _METER_SLIM if slim else _METER_FULL
if fraction is None:
steps = 0
else:
frac = min(1.0, max(0.0, fraction))
steps = max(1, round(frac * width * 2))
full, half = divmod(steps, 2)
bar = Text(full_glyph * full + half_glyph * half, style=style)
rest = width - full - half
if track is not None:
bar.append(full_glyph * rest, style=track)
else:
bar.append(" " * rest)
return bar
def axis_caption(
chars: int,
label_at: Callable[[float], str],
*,
style: str = "faint",
) -> Text:
"""The caption line under a timeline: edge labels plus whatever marks fit between.
Every chart used to caption only its ends (``oldest … now``); this asks
``label_at`` for the labels at the quarter points too and keeps the densest set
that fits — quarters, else the midpoint, else just the two ends — so a wide chart
reads its timescale without counting cells. The first label is left-aligned on the
chart's left edge, the last right-aligned on its right edge, and interior labels
are centred on the fraction they describe, each separated by at least two blank
cells so they never run together.
Args:
chars: The chart's width in character cells (the caption matches it).
label_at: Maps a position fraction (``0.0`` = the oldest column, ``1.0`` =
now) to its label.
style: Style the whole caption is drawn in.
Returns:
A :class:`Text` exactly ``chars`` cells wide.
"""
for segments in (4, 2, 1):
fractions = [i / segments for i in range(segments + 1)]
labels = [label_at(f) for f in fractions]
cells: list[str] = [" "] * chars
taken: list[tuple[int, int]] = [] # placed [start, end) spans, in order
ok = True
for frac, label in zip(fractions, labels):
if frac == 0.0:
start = 0
elif frac == 1.0:
start = chars - len(label)
else:
centre = round(frac * chars)
start = min(chars - len(label), max(0, centre - len(label) // 2))
end = start + len(label)
if end > chars or any(start < e + 2 and s < end + 2 for s, e in taken):
ok = False
break
taken.append((start, end))
cells[start:end] = label
if ok:
return Text("".join(cells), style=style)
# Even the two edge labels collide: keep the left one and let it stand alone.
label = label_at(0.0)[:chars]
return Text(label.ljust(chars), style=style)
def _baseline_row(lo: float, hi: float, total: int) -> int:
"""The dot row (from the chart bottom) the zero baseline sits on.
+6 -24
View File
@@ -71,6 +71,7 @@ from rich.text import Text
from ..core.models import PATH_TRACE_TARGET, TraceResult, TraceStats
from ..services import trace_runner
from ..services.topology import render_forced_spec
from .braillechart import meter
from .theme import snr_style
from .tui.render import render_lines, render_to_ansi
from .tui.screen import Screen
@@ -93,16 +94,6 @@ _BAR_WIDTH = 8
_BAR_SNR_MIN = -15.0
_BAR_SNR_MAX = 10.0
#: A half-filled bar step: a braille cell with only its left column of the middle
#: two dot rows raised (dots 2, 3 — the top row, dots 1 and 4, and the bottom row,
#: dots 7 and 8, both stay blank, so the glyph sits mid-cell instead of hugging
#: either edge).
_BAR_HALF = "⠆"
#: A full bar step: both columns of the same middle two rows raised (dots 2, 3,
#: 5, 6).
_BAR_FULL = "⠶"
#: The sample counts the Sample count dialog offers: how many traces one Trace action
#: runs, paced between transmissions.
SAMPLE_CHOICES = (1, 2, 3, 5, 8)
@@ -122,13 +113,9 @@ PathFlow = Callable[[str], Awaitable[Optional[str]]]
def snr_bar(snr: Optional[float], width: int = _BAR_WIDTH) -> Text:
"""Render an SNR reading as a horizontal quality bar in the shared SNR colours.
Packs two fill steps into each character — a half step (:data:`_BAR_HALF`) then
a full step (:data:`_BAR_FULL`) — for 16-step resolution in ``width`` characters
instead of needing one cell per step. The unlit remainder draws in the same
:data:`_BAR_FULL` glyph, dimmed to the ``track`` style — a background the reading
fills in, not a run of unrelated dots. The one glyph a single colour can't split
is the boundary half-step itself: it carries the reading's colour, not the
track's, since it's still part of what was actually measured.
The slim-on-a-track flavour of the app's braille :func:`~meshterm.ui.braillechart.meter`:
the reading maps onto :data:`_BAR_SNR_MIN` → :data:`_BAR_SNR_MAX` and fills a dark
same-glyph track at two steps per cell.
Args:
snr: The reading in dB, or ``None`` (renders as an entirely unlit track).
@@ -139,15 +126,10 @@ def snr_bar(snr: Optional[float], width: int = _BAR_WIDTH) -> Text:
coloured by :func:`~meshterm.ui.theme.snr_style`.
"""
if snr is None:
return Text(_BAR_FULL * width, style="track")
return meter(None, width, style="track", slim=True, track="track")
span = _BAR_SNR_MAX - _BAR_SNR_MIN
frac = min(1.0, max(0.0, (snr - _BAR_SNR_MIN) / span))
steps = max(1, round(frac * width * 2))
full, half = divmod(steps, 2)
filled = _BAR_FULL * full + _BAR_HALF * half
bar = Text(filled, style=snr_style(snr))
bar.append(_BAR_FULL * (width - len(filled)), style="track")
return bar
return meter(frac, width, style=snr_style(snr), slim=True, track="track")
class TracingDialog(Screen):
+5 -1
View File
@@ -13,10 +13,14 @@ from datetime import timedelta
import pytest
from meshterm.core.models import Hop, TraceResult, TraceStats, TxLevelResult, TxOptResult, utcnow
from meshterm.ui.braillechart import _METER_SLIM
from meshterm.ui.theme import snr_style
from meshterm.ui.trace_screen import _BAR_FULL, _BAR_HALF, _BAR_WIDTH, TraceScreen, snr_bar
from meshterm.ui.trace_screen import _BAR_WIDTH, TraceScreen, snr_bar
from meshterm.ui.tx_screen import TxSweepScreen
# The slim meter's fill glyphs (the SNR bar draws through the shared braille meter).
_BAR_FULL, _BAR_HALF = _METER_SLIM
class _FakeSession:
"""The session capabilities the screens use directly: repaints and the dialog stack."""