diff --git a/meshterm/ui/dashboard_screen.py b/meshterm/ui/dashboard_screen.py index 3ec9fc8..ca50f0b 100644 --- a/meshterm/ui/dashboard_screen.py +++ b/meshterm/ui/dashboard_screen.py @@ -6,10 +6,11 @@ stacks four reads of the mesh, coarsest first: * **Activity** — a tall braille bar chart of *every* packet the hub hears (adverts, telemetry, RX-logged packets, messages, acks), one dot column per minute — braille's full horizontal resolution, two minutes per character — stretched across whatever - width the terminal offers, newest at the left with the count scale mirrored on both - edges. The header indicator's big sibling, drawn from the same - :meth:`~meshterm.services.monitor_service.MonitorService` buckets. A pulse line - beneath it reads the rate, who's been heard, and the busiest node of the window. + width the terminal offers, newest at the right (the app-wide timeline direction) + with the count scale mirrored on both edges. The header indicator's big sibling, + drawn from the same :meth:`~meshterm.services.monitor_service.MonitorService` + buckets. A pulse line beneath it reads the rate, who's been heard, and the busiest + node of the window. * **Traffic** — the session's tallies by packet class, each with a proportional bar, from the monitor's kind counters. * **RF health** — the trailing window's reception quality: median SNR (on the trace @@ -37,6 +38,7 @@ from rich.text import Text from ..core.events import EventKind, MeshEvent from ..core.models import NODE_TYPE_REPEATER, Observation, utcnow from ..persistence.repository import ACTIVITY_WINDOW +from .braillechart import timeline_rows from .theme import snr_style from .trace_screen import snr_bar from .tui.render import render_lines @@ -60,11 +62,6 @@ _FEED_NAME_WIDTH = 18 #: How many braille rows tall the activity chart draws (each row is four dot rows). _CHART_ROWS = 3 -#: Braille dot masks for one cell-column filled bottom-up to height 0–4 (left column: -#: dots 7, 3, 2, 1 top-down; right column: dots 8, 6, 5, 4). -_COL_LEFT = (0x00, 0x40, 0x44, 0x46, 0x47) -_COL_RIGHT = (0x00, 0x80, 0xA0, 0xB0, 0xB8) - #: Display style per packet class, shared by the traffic panel and the feed. _KIND_STYLES = { "advert": "accent", @@ -113,55 +110,6 @@ def _axis_labels(peak: int, rows: int) -> list[str]: return labels -def braille_bars(values: list[int], *, rows: int = _CHART_ROWS) -> list[Text]: - """Render ``values`` as a braille bar chart, one *dot column* per value. - - Braille offers two dot columns per character cell — the font's full horizontal - resolution — so consecutive values pair up into one cell, each rising to its own - height. Bars are scaled so the window's peak fills all ``rows × 4`` dot rows, and - any non-zero value lights at least one dot, so a lone packet never vanishes. A - silent dot column keeps a faint one-dot floor on the bottom row — the flatline - convention of the app's sparklines — so the chart floor is always visible. - - Args: - values: The bucket counts, drawn left to right (two per character cell; an - odd count is padded with one silent column). - rows: How many braille rows tall the chart is. - - Returns: - ``rows`` :class:`Text` lines, top row first, ``ceil(len(values) / 2)`` - characters wide. - """ - peak = max(values, default=0) - total_dots = rows * 4 - heights = [ - (0 if peak == 0 or v <= 0 else max(1, round(v / peak * total_dots))) - for v in values - ] - if len(heights) % 2: - heights.append(0) - lines: list[Text] = [] - for row in range(rows): - floor = (rows - 1 - row) * 4 # dot rows below this braille row - line = Text() - for left, right in zip(heights[0::2], heights[1::2]): - lf = min(4, max(0, left - floor)) - rf = min(4, max(0, right - floor)) - if lf or rf: - mask = _COL_LEFT[lf] | _COL_RIGHT[rf] - if row == rows - 1: - # Keep the floor continuous under a half-silent cell: the silent - # dot column still shows its bottom baseline dot. - mask |= (0x40 if lf == 0 else 0) | (0x80 if rf == 0 else 0) - line.append(chr(0x2800 | mask), style="ok") - elif row == rows - 1: - line.append(chr(0x2800 | 0x40 | 0x80), style="faint") # the flatline - else: - line.append(chr(0x2800)) # blank braille keeps the grid monospace - lines.append(line) - return lines - - class DashboardScreen(Screen): """The live mesh overview. Renders state and scrolls; the opener feeds it.""" @@ -274,12 +222,13 @@ class DashboardScreen(Screen): # -- activity -- def _activity_section(self, width: int) -> list[RenderableType]: - """The all-packet chart — newest minute at the left — plus the pulse line. + """The all-packet chart — newest minute at the right — plus the pulse line. One dot column per minute, two per character cell, stretched across every cell the terminal offers between the two scale gutters; a wider terminal - simply shows more history. The scale is mirrored on both edges so the counts - are readable from either end of a wide chart. + simply shows more history. Time runs oldest→now left to right (every + MeshTerm timeline's direction), and the scale is mirrored on both edges so + the counts are readable from either end of a wide chart. """ histogram = list(self._activity()) # newest first, one count per minute # Size the label lane from the whole histogram's peak (not just the visible @@ -293,7 +242,7 @@ class DashboardScreen(Screen): heading = Text("Activity", style="accent") heading.append(" · every packet heard · one minute per dot column", style="muted") - chart_rows = braille_bars(shown) + chart_rows = timeline_rows(list(reversed(shown)), rows=_CHART_ROWS) marks = _axis_labels(peak, len(chart_rows)) out: list[RenderableType] = [heading] for mark, row in zip(marks, chart_rows): @@ -307,9 +256,9 @@ class DashboardScreen(Screen): out.append(axis) span = "−" + _span_label(minutes) caption = Text(" " * (label_w + 2)) - caption.append("now", style="faint") - caption.append(" " * max(1, chars - len("now") - len(span))) caption.append(span, style="faint") + caption.append(" " * max(1, chars - len(span) - len("now"))) + caption.append("now", style="faint") out.append(caption) out.append(Text()) out.append(self._pulse_line(shown, width)) diff --git a/meshterm/ui/timemachine_screen.py b/meshterm/ui/timemachine_screen.py index ad2027c..5adec26 100644 --- a/meshterm/ui/timemachine_screen.py +++ b/meshterm/ui/timemachine_screen.py @@ -13,9 +13,11 @@ charts and stats over a switchable window (``w`` cycles 24 h → 7 d → 30 d arrivals of the window (nodes heard for the first time ever), and the all-time totals. -Unlike the dashboard's live chart (newest at the left, sliding), these are *histories* -and read chronologically: oldest at the left, now at the right. Everything is stored -data — no device is needed and nothing transmits. +Charts read chronologically — oldest at the left, now at the right, the app-wide +timeline direction — and draw through :mod:`~meshterm.ui.braillechart`, so the grey +baseline always marks zero: the SNR band's readings hang below it or rise above it +by their actual sign. Everything is stored data — no device is needed and nothing +transmits. """ from __future__ import annotations @@ -28,7 +30,7 @@ from rich.console import Group, RenderableType from rich.text import Text from ..core.models import utcnow -from .dashboard_screen import _COL_LEFT, _COL_RIGHT, braille_bars +from .braillechart import chart_span, timeline_rows from .theme import snr_style from .tui.render import render_lines from .tui.screen import Screen @@ -51,9 +53,14 @@ MESH = ("mesh",) #: Character cells the charts keep clear for their side gutters. _GUTTER = 4 -#: How many braille rows tall the charts draw. +#: How many braille rows tall the volume/rhythm charts draw. _CHART_ROWS = 2 +#: How many braille rows tall the SNR band draws — one more than the volume charts, +#: because zero-anchoring (readings hang below the grey zero line by their actual +#: depth) spends some dots on honesty and the extra row buys the swing back. +_SNR_ROWS = 3 + def bucketize(stamps: list[datetime], start: datetime, end: datetime, buckets: int) -> list[int]: """Fold timestamps into ``buckets`` equal slices of ``[start, end]``, oldest first.""" @@ -77,58 +84,9 @@ def bucket_medians( return [median(values) if values else None for values in grouped] -def band_rows( - values: list[Optional[float]], *, rows: int = _CHART_ROWS -) -> tuple[list[Text], float, float]: - """Render sparse readings as a braille band scaled between their own extremes. - - The volume charts scale zero-to-peak (:func:`braille_bars`); readings like SNR need - a *band* — the chart floor is the window's minimum, not zero, so a swing from - −12 dB to −4 dB still fills the height. Two values share each cell (braille's full - horizontal resolution), every present value lights at least one dot, empty columns - keep the faint one-dot floor, and each cell is coloured by its readings' quality - (the shared SNR palette). - - Args: - values: Per-bucket readings, oldest first; ``None`` marks an empty bucket. - rows: How many braille rows tall the band is. - - Returns: - ``(lines, lo, hi)``: the rendered rows plus the scale's extremes (both 0.0 - when no reading is present at all). - """ - present = [v for v in values if v is not None] - lo, hi = (min(present), max(present)) if present else (0.0, 0.0) - spread = max(1e-9, hi - lo) - total = rows * 4 - heights = [ - 0 if v is None else max(1, 1 + round((v - lo) / spread * (total - 1))) - for v in values - ] - padded = heights + [0] * (len(heights) % 2) - value_pairs = list(values) + [None] * (len(values) % 2) - lines: list[Text] = [] - for row in range(rows): - floor = (rows - 1 - row) * 4 - line = Text() - for i in range(0, len(padded), 2): - lf = min(4, max(0, padded[i] - floor)) - rf = min(4, max(0, padded[i + 1] - floor)) - cell = [v for v in value_pairs[i : i + 2] if v is not None] - if lf or rf: - mask = _COL_LEFT[lf] | _COL_RIGHT[rf] - if row == rows - 1: - mask |= (0x40 if lf == 0 else 0) | (0x80 if rf == 0 else 0) - line.append( - chr(0x2800 | mask), - style=snr_style(sum(cell) / len(cell)) if cell else "brand", - ) - elif row == rows - 1: - line.append(chr(0x2800 | 0x40 | 0x80), style="faint") - else: - line.append(chr(0x2800)) - lines.append(line) - return lines, lo, hi +def _snr_cell_style(values: list[float]) -> str: + """Colour one SNR-band cell by its readings' quality (the shared SNR palette).""" + return snr_style(sum(values) / len(values)) def _chart_block( @@ -252,11 +210,11 @@ def _node_sections( out: list[RenderableType] = [] stamps = [o.observed_at for o in observations] out.append( - _heading("Volume", f"{len(observations)} receptions · oldest left") + _heading("Volume", f"{len(observations)} receptions · now at the right") ) out.extend( _chart_block( - braille_bars(bucketize(stamps, start, now, buckets)), + timeline_rows(bucketize(stamps, start, now, buckets), rows=_CHART_ROWS), _when_label(start), "now", chars, ) ) @@ -265,10 +223,15 @@ def _node_sections( (o.observed_at, float(o.snr)) for o in observations if o.snr is not None ] if snr_pairs: - rows, lo, hi = band_rows(bucket_medians(snr_pairs, start, now, buckets)) + medians = bucket_medians(snr_pairs, start, now, buckets) + lo, hi = chart_span(medians) + rows = timeline_rows(medians, rows=_SNR_ROWS, style=_snr_cell_style) out.append(Text()) out.append( - _heading("SNR", f"median per slice · scale {lo:+.1f} → {hi:+.1f} dB") + _heading( + "SNR", + f"median per slice · grey line = 0 · scale {lo:+.1f} → {hi:+.1f} dB", + ) ) out.extend(_chart_block(rows, _when_label(start), "now", chars)) @@ -277,7 +240,7 @@ def _node_sections( hours[stamp.astimezone().hour] += 1 out.append(Text()) out.append(_heading("Rhythm", "receptions by local hour of day")) - out.extend(_chart_block(braille_bars(hours), "0 h", "23 h", 12)) + out.extend(_chart_block(timeline_rows(hours, rows=_CHART_ROWS), "0 h", "23 h", 12)) out.append(Text()) out.append(_heading("Record", "this window")) @@ -335,17 +298,21 @@ def _mesh_sections( out: list[RenderableType] = [] packets = [d[1] for d in shown] out.append( - _heading("Packets per day", f"UTC days · peak {max(packets)} · oldest left") + _heading("Packets per day", f"UTC days · peak {max(packets)} · today at the right") ) out.extend( - _chart_block(braille_bars(packets), shown[0][0], shown[-1][0], chars) + _chart_block( + timeline_rows(packets, rows=_CHART_ROWS), shown[0][0], shown[-1][0], chars + ) ) nodes = [d[2] for d in shown] out.append(Text()) out.append(_heading("Nodes per day", f"distinct nodes heard · peak {max(nodes)}")) out.extend( - _chart_block(braille_bars(nodes), shown[0][0], shown[-1][0], chars) + _chart_block( + timeline_rows(nodes, rows=_CHART_ROWS), shown[0][0], shown[-1][0], chars + ) ) arrivals = ctx.repo.first_seen(since=since) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 70ab5e8..26b5dc6 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -1,7 +1,8 @@ -"""Dashboard tests: the braille bar chart, the live screen's state, and its data feeds. +"""Dashboard tests: the live screen's state, its chart plumbing, and its data feeds. The screen is driven headless against fake sessions and canned observations, the same -approach as the live trace/TX screen tests. +approach as the live trace/TX screen tests. (The braille chart renderer itself is +covered in ``test_braillechart``.) """ from __future__ import annotations @@ -14,7 +15,7 @@ from meshterm.core.events import MeshEvent from meshterm.core.models import Ack, Message, Observation, utcnow from meshterm.persistence.repository import Repository from meshterm.services.monitor_service import ACTIVITY_BUCKETS -from meshterm.ui.dashboard_screen import DashboardScreen, braille_bars +from meshterm.ui.dashboard_screen import DashboardScreen class _FakeSession: @@ -52,51 +53,6 @@ def _stripped(lines: list[str]) -> list[str]: return [re.sub(r"\x1b\[[0-9;]*m", "", line) for line in lines] -# --- braille_bars -------------------------------------------------------------------- - - -def test_braille_bars_scales_the_peak_to_full_height() -> None: - """The window's peak fills the chart; values pair up two dot columns per cell.""" - rows = braille_bars([12, 12, 0, 0], rows=3) - assert len(rows) == 3 - assert all(len(r.plain) == 2 for r in rows) - # The peak pair fills both dot columns of its cell on the top row; the silent - # pair is blank there and keeps the faint two-dot floor on the bottom row. - assert rows[0].plain[0] == chr(0x2800 | 0x47 | 0xB8) - assert rows[0].plain[1] == chr(0x2800) - assert rows[2].plain[1] == chr(0x2800 | 0x40 | 0x80) - - -def test_braille_bars_gives_each_dot_column_its_own_height() -> None: - """Two values sharing a cell rise independently — braille's full resolution.""" - rows = braille_bars([12, 6], rows=3) - assert rows[0].plain[0] == chr(0x2800 | 0x47) # left full, right below - assert rows[1].plain[0] == chr(0x2800 | 0x47 | 0xA0) # right's head: 2 dots - assert rows[2].plain[0] == chr(0x2800 | 0x47 | 0xB8) # both full at the base - - -def test_braille_bars_never_hides_a_lone_packet() -> None: - """A tiny non-zero value still lights (in the lit style, not the faint floor's).""" - rows = braille_bars([1, 0, 1000, 1000], rows=3) - first_span = rows[2].spans[0] - assert first_span.start == 0 and first_span.style == "ok" - - -def test_braille_bars_all_silent_is_a_flatline() -> None: - """With nothing heard the chart is a floor line, not a divide-by-zero.""" - rows = braille_bars([0, 0, 0, 0, 0, 0], rows=2) - assert rows[1].plain == chr(0x2800 | 0x40 | 0x80) * 3 - - -def test_braille_bars_pads_an_odd_tail_column() -> None: - """An odd value count still renders whole cells (the tail column stays silent).""" - rows = braille_bars([4], rows=1) - assert len(rows[0].plain) == 1 - # The lone value fills its left dot column; the padded right column keeps the - # floor dot so the baseline stays continuous. - assert rows[0].plain[0] == chr(0x2800 | 0x47 | 0x80) - - # --- the screen ---------------------------------------------------------------------- @@ -115,15 +71,22 @@ def test_dashboard_renders_all_four_sections() -> None: assert "advert" in body and "Alice" in body and "YUL" in body -def test_dashboard_activity_chart_reads_newest_left_with_mirrored_scale() -> None: - """'now' anchors the left edge and the peak count marks both gutters.""" +def test_dashboard_activity_chart_reads_newest_right_with_mirrored_scale() -> None: + """'now' anchors the right edge and the peak count marks both gutters.""" screen = _screen(histogram=[9] + [0] * (ACTIVITY_BUCKETS - 1)) lines = _stripped(screen.render_body(80)) top = next(line for line in lines if "┤" in line) assert top.strip().startswith("9 ┤") assert top.rstrip().endswith("├ 9") caption = next(line for line in lines if "now" in line) - assert caption.index("now") < caption.index("−") # newest left, oldest right + assert caption.index("−") < caption.index("now") # oldest left, newest right + # The lone newest-minute burst draws against the chart's right gutter, and the + # left half of the chart is bare flatline. + chart = [line for line in lines if "┤" in line or "│" in line] + bottom = chart[-1] + left_half = bottom[3 : 3 + (len(bottom) - 6) // 2] + assert all(ch in (chr(0x2800), chr(0x2800 | 0x40 | 0x80), " ") for ch in left_half) + assert any(0x2800 <= ord(ch) <= 0x28FF and ord(ch) & 0x3F for ch in chart[0]) def test_dashboard_activity_chart_fills_the_width() -> None: diff --git a/tests/test_timemachine.py b/tests/test_timemachine.py index f7aed3a..e35f3b5 100644 --- a/tests/test_timemachine.py +++ b/tests/test_timemachine.py @@ -17,7 +17,7 @@ from meshterm.ui.timemachine_screen import ( TimeMachineScreen, _mesh_sections, _node_sections, - band_rows, + _snr_cell_style, bucket_medians, bucketize, ) @@ -119,16 +119,21 @@ def test_bucketize_and_medians_split_the_span() -> None: assert medians == [5.0, -4.0] -def test_band_rows_scales_between_extremes() -> None: - """The band floor is the minimum reading, not zero, and gaps stay faint.""" - rows, lo, hi = band_rows([-12.0, None, -4.0, -8.0], rows=2) - assert (lo, hi) == (-12.0, -4.0) +def test_snr_band_hangs_negative_readings_below_zero() -> None: + """All-negative SNR medians hang from a grey zero ceiling, coloured by quality.""" + from meshterm.ui.braillechart import chart_span, timeline_rows + + medians = [-12.0, None, -4.0, -8.0] + assert chart_span(medians) == (-12.0, 0.0) # zero is always on the scale + rows = timeline_rows(medians, rows=2, style=_snr_cell_style) assert len(rows) == 2 and len(rows[0].plain) == 2 - bottom = rows[1].plain - # The minimum lights exactly one dot; the maximum fills its column. - assert bottom[0] != chr(0x2800) - top = rows[0].plain - assert top[1] != chr(0x2800) # -4.0 (the max) reaches the top row + # The worst reading (−12) hangs the full height; −8 dips its tail into the + # bottom row (right column only) while the shallower −4 stays out of it. + assert rows[1].plain[0] != chr(0x2800) # the −12 column reaches the bottom row + assert rows[1].plain[1] == chr(0x2808) # −8's tail, no −4 (left-column) dots + # Cells take the shared SNR palette, driven by their own readings. + assert rows[0].spans[0].style == _snr_cell_style([-12.0]) + assert rows[0].spans[1].style == _snr_cell_style([-4.0, -8.0]) # --- the pages --------------------------------------------------------------------------