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:
1 parent
ffa834182b
commit
f9411bfc9b
3 files changed
+134
-26
No files matched your search
+123
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user