diff --git a/meshterm/ui/trace_screen.py b/meshterm/ui/trace_screen.py index 81a93a5..c631120 100644 --- a/meshterm/ui/trace_screen.py +++ b/meshterm/ui/trace_screen.py @@ -83,14 +83,26 @@ if TYPE_CHECKING: #: Seconds between spinner frames while a trace or probe is in flight. _SPINNER_INTERVAL = 0.12 -#: How wide the per-hop SNR quality bars draw, in cells. -_BAR_WIDTH = 16 +#: How wide the per-hop SNR quality bars draw, in characters. Each character packs +#: two fill steps (see :func:`snr_bar`), so the bar reads at 16-step resolution in +#: half the columns a one-step-per-cell block bar would need. +_BAR_WIDTH = 8 #: The SNR range the bars span, in dB: -15 (barely readable) to +10 (excellent). Values #: outside clamp to the ends, so the bar always shows *something* for a heard hop. _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) @@ -110,21 +122,27 @@ 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. + Args: snr: The reading in dB, or ``None`` (renders as an empty, muted track). - width: Total bar track width in cells. + width: Bar track width in characters (each worth two fill steps). Returns: - A :class:`Text` of filled blocks over a faint track, coloured by - :func:`~meshterm.ui.theme.snr_style`. + A :class:`Text` of filled braille cells over a faint dotted track, + coloured by :func:`~meshterm.ui.theme.snr_style`. """ if snr is None: return Text("·" * width, style="faint") span = _BAR_SNR_MAX - _BAR_SNR_MIN frac = min(1.0, max(0.0, (snr - _BAR_SNR_MIN) / span)) - filled = max(1, round(frac * width)) - bar = Text("▆" * filled, style=snr_style(snr)) - bar.append("·" * (width - filled), style="faint") + 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("·" * (width - len(filled)), style="faint") return bar diff --git a/tests/test_live_screens.py b/tests/test_live_screens.py index 59cf682..ed8d58a 100644 --- a/tests/test_live_screens.py +++ b/tests/test_live_screens.py @@ -13,7 +13,7 @@ from datetime import timedelta import pytest from meshterm.core.models import Hop, TraceResult, TraceStats, TxLevelResult, TxOptResult, utcnow -from meshterm.ui.trace_screen import TraceScreen, snr_bar +from meshterm.ui.trace_screen import _BAR_FULL, _BAR_HALF, _BAR_WIDTH, TraceScreen, snr_bar from meshterm.ui.tx_screen import TxSweepScreen @@ -53,19 +53,37 @@ def _plain(lines: list[str]) -> str: # --- snr_bar ------------------------------------------------------------------- +def _filled_steps(bar: str) -> int: + """Count a rendered bar's fill steps — two per full braille cell, one per half.""" + return bar.count(_BAR_FULL) * 2 + bar.count(_BAR_HALF) + + def test_snr_bar_scales_with_signal_quality() -> None: """A stronger signal fills more of the track; None renders an empty muted track.""" weak = snr_bar(-12.0).plain strong = snr_bar(8.0).plain - assert weak.count("▆") < strong.count("▆") - assert len(weak) == len(strong) # the track width is constant - assert "▆" not in snr_bar(None).plain + assert _filled_steps(weak) < _filled_steps(strong) + assert len(weak) == len(strong) == _BAR_WIDTH # the track width is constant + assert _filled_steps(snr_bar(None).plain) == 0 def test_snr_bar_clamps_out_of_range_readings() -> None: """Readings beyond the display range clamp to the ends instead of over/underflowing.""" - assert snr_bar(99.0).plain.count("▆") == snr_bar(10.0).plain.count("▆") - assert snr_bar(-99.0).plain.count("▆") == 1 # a heard hop always shows something + assert _filled_steps(snr_bar(99.0).plain) == _filled_steps(snr_bar(10.0).plain) + assert _filled_steps(snr_bar(-99.0).plain) == 1 # a heard hop always shows something + + +def test_snr_bar_packs_two_steps_per_character() -> None: + """16 steps of resolution pack into 8 characters: full cells, then one trailing half.""" + one_step = snr_bar(-13.4375).plain # frac = 1/16 of the -15..+10 span + assert one_step[0] == _BAR_HALF + assert one_step[1:] == "·" * (_BAR_WIDTH - 1) + + three_steps = snr_bar(-10.3125).plain # frac = 3/16 → one full cell, one half + assert three_steps[:2] == _BAR_FULL + _BAR_HALF + assert three_steps[2:] == "·" * (_BAR_WIDTH - 2) + + assert snr_bar(10.0).plain == _BAR_FULL * _BAR_WIDTH # top of range: every cell full # --- _previous_outbound -----------------------------------------------------------