diff --git a/meshterm/services/monitor_service.py b/meshterm/services/monitor_service.py index 88bae1e..d7c353c 100644 --- a/meshterm/services/monitor_service.py +++ b/meshterm/services/monitor_service.py @@ -80,6 +80,9 @@ class MonitorService: # "total" is this plus what we capture this session (this process is the only # writer during an interactive session), avoiding a DB count on every repaint. self._start_total = ctx.repo.observation_count() + # The minute this session began: buckets at or after it hold live traffic, + # older ones the seeded history — the boundary the activity charts colour by. + self._start_bucket = int(time.time() // ACTIVITY_BUCKET_S) self._seed_from_history() def _seed_from_history(self) -> None: @@ -131,6 +134,20 @@ class MonitorService: bucket = int(time.time() // ACTIVITY_BUCKET_S) return tuple(self._activity.get(bucket - i, 0) for i in range(ACTIVITY_BUCKETS)) + def activity_session_flags(self) -> tuple[bool, ...]: + """Whether each histogram bucket holds this session's own traffic. + + Aligned with :meth:`activity_histogram` (newest first): ``True`` for buckets + at or after the session's first minute, ``False`` for the seeded history — + the split the activity charts use to draw live traffic green and a previous + session's grey. + + Returns: + :data:`ACTIVITY_BUCKETS` flags, newest first. + """ + bucket = int(time.time() // ACTIVITY_BUCKET_S) + return tuple(bucket - i >= self._start_bucket for i in range(ACTIVITY_BUCKETS)) + def kind_counts(self) -> dict[str, int]: """Tallies by packet class: advert/telemetry/packet, message, ack. diff --git a/meshterm/ui/braillechart.py b/meshterm/ui/braillechart.py index bddc081..f6c2e96 100644 --- a/meshterm/ui/braillechart.py +++ b/meshterm/ui/braillechart.py @@ -85,6 +85,7 @@ def timeline_rows( span: Optional[tuple[float, float]] = None, style: CellStyle = "ok", baseline_style: str = "faint", + column_styles: Optional[Sequence[str]] = None, ) -> list[Text]: """Render a chronological series as a braille bar chart, newest at the right. @@ -103,6 +104,10 @@ def timeline_rows( style: Style for lit cells: a Rich style name, or a callable given each cell's present readings (for per-slice colouring). baseline_style: Style for the zero line where nothing covers it. + column_styles: Optional per-column styles, aligned with ``values``, + overriding ``style`` — how the activity charts dim history recorded by + an earlier session. A cell straddling two styles takes its newer + (right) lit column's. Returns: ``rows`` :class:`Text` lines, top row first, ``ceil(len(values)/2)`` @@ -124,7 +129,7 @@ def timeline_rows( else: height = max(1, round(value / lo * down)) bars.append((base - height + 1, base)) - return _assemble(bars, list(values), rows, base, style, baseline_style) + return _assemble(bars, list(values), rows, base, style, baseline_style, column_styles) def activity_sparkline( @@ -133,6 +138,7 @@ def activity_sparkline( buckets: int, *, style: str = "ok", + column_styles: Optional[Sequence[str]] = None, ) -> Text: """A one-row activity sparkline over a newest-first histogram, "now" rightmost. @@ -148,16 +154,25 @@ def activity_sparkline( levels: Ascending count thresholds for bar heights 1..4. buckets: How many buckets to draw (half this many characters). style: Style for lit cells. + column_styles: Optional per-bucket styles aligned with ``histogram`` + (newest first, reversed here alongside it), overriding ``style`` — + how the header pulse dims buckets recorded by an earlier session. Returns: A styled Rich :class:`Text` of ``buckets / 2`` braille characters. """ window = (tuple(histogram) + (0,) * buckets)[:buckets] + per_column: Optional[list[str]] = None + if column_styles is not None: + padded = (list(column_styles) + [style] * buckets)[:buckets] + per_column = list(reversed(padded)) bars: list[Optional[tuple[int, int]]] = [] for count in reversed(window): height = bisect_right(levels, count) bars.append((0, height - 1) if height else None) - return _assemble(bars, [float(c) for c in reversed(window)], 1, 0, style, "faint")[0] + return _assemble( + bars, [float(c) for c in reversed(window)], 1, 0, style, "faint", per_column + )[0] #: The meter's fill glyphs, ``(full step, half step)``, by profile. A *full-height* @@ -323,6 +338,7 @@ def _assemble( base: int, style: CellStyle, baseline_style: str, + column_styles: Optional[Sequence[str]] = None, ) -> list[Text]: """Assemble bar spans into styled braille rows (the shared cell walk). @@ -339,6 +355,9 @@ def _assemble( base: The zero baseline's dot row. style: Fixed style, or per-cell callable (see :data:`CellStyle`). baseline_style: Style for baseline-only cells. + column_styles: Per-column style overrides aligned with ``bars``; a cell + takes its right column's style when that column is lit, else its + left's (the newer reading wins the shared cell). Returns: ``rows`` :class:`Text` lines, top row first. @@ -357,7 +376,9 @@ def _assemble( right = _column_bits(bars[i + 1], floor, _RIGHT_BITS) if left or right: mask = left | right | base_bit_left | base_bit_right - if callable(style): + if column_styles is not None and i + 1 < len(column_styles): + cell_style = column_styles[i + 1] if right else column_styles[i] + elif callable(style): present = [v for v in values[i : i + 2] if v is not None] cell_style = style(present) if present else baseline_style else: diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index 5ee9903..1aa9ea6 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -155,9 +155,17 @@ def _header(ctx: AppContext, cache: dict, width: int) -> Text: # Two dot columns per cell: every cell left of the row's edge shows two minutes. room = width - header.cell_len if room > 0: + # Buckets seeded from a previous session's stored history draw grey; only + # traffic this session actually heard pulses green. + styles = [ + "ok" if live else "muted" for live in ctx.monitor.activity_session_flags() + ] header.append_text( activity_sparkline( - ctx.monitor.activity_histogram(), _HEADER_ACTIVITY_LEVELS, room * 2 + ctx.monitor.activity_histogram(), + _HEADER_ACTIVITY_LEVELS, + room * 2, + column_styles=styles, ) ) return header diff --git a/tests/test_header.py b/tests/test_header.py index f37c8b2..567cb53 100644 --- a/tests/test_header.py +++ b/tests/test_header.py @@ -12,7 +12,10 @@ def _ctx(histogram=(), unread=0, alerts=0) -> SimpleNamespace: return SimpleNamespace( mock=True, chat=SimpleNamespace(unread_total=lambda: unread), - monitor=SimpleNamespace(activity_histogram=lambda: tuple(histogram)), + monitor=SimpleNamespace( + activity_histogram=lambda: tuple(histogram), + activity_session_flags=lambda: (True,) * len(tuple(histogram)), + ), watchtower=SimpleNamespace(unacked_count=lambda: alerts), )