diff --git a/meshterm/ui/braillechart.py b/meshterm/ui/braillechart.py index dd57563..7cff982 100644 --- a/meshterm/ui/braillechart.py +++ b/meshterm/ui/braillechart.py @@ -12,7 +12,10 @@ histories — renders through this module, so they all share the same three rule bars grow *from that line*, so a silent stretch reads as a flatline, never a hole. When a series carries negative values (an SNR history, say) the baseline sits at zero's height inside the chart — positive readings rise above it, - negative ones hang below — rather than being nailed to the chart floor. + negative ones hang below — rather than being nailed to the chart floor. The one + deliberate break in the line is :data:`GAP`, a column that renders *fully blank* + down to the axis, used to notch adjacent bars apart (the Time Machine's per-day + charts space one day from the next this way). * **Two readings per cell.** Braille offers two dot columns per character cell; every chart uses both, so each cell shows two consecutive readings and the chart draws at twice the horizontal resolution of the cells it occupies. @@ -56,6 +59,23 @@ _RIGHT_BITS = (0x80, 0x20, 0x10, 0x08) CellStyle = Union[str, Callable[[list[float]], str]] +class _Gap: + """The type of :data:`GAP`; a private singleton, so ``value is GAP`` identifies it.""" + + __slots__ = () + + def __repr__(self) -> str: # pragma: no cover - a debugging aid only + return "GAP" + + +#: A timeline value marking a hard gap: the column renders *fully blank* — no bar and +#: no zero baseline — so it breaks the "grey is zero" flatline on purpose. ``None`` and +#: ``0`` still draw the faint zero line (a silent reading is still a reading); only +#: ``GAP`` punches a hole in it, used to notch adjacent day bars apart in the Time +#: Machine's per-day charts so same-height neighbours never fuse into one solid block. +GAP = _Gap() + + def chart_span( values: Sequence[Optional[float]], span: Optional[tuple[float, float]] = None ) -> tuple[float, float]: @@ -68,13 +88,14 @@ def chart_span( label and the drawing can never disagree. Args: - values: The chart's readings (``None`` marks an empty slot). + values: The chart's readings (``None`` marks an empty slot; :data:`GAP` a + hard gap — neither carries a magnitude, so both sit out the span). span: An optional wider range to honour (it too is folded around zero). Returns: ``(lo, hi)`` with ``lo <= 0 <= hi``; ``(0.0, 0.0)`` for an empty series. """ - present = [v for v in values if v is not None] + present = [v for v in values if v is not None and v is not GAP] if span is not None: present = [*present, *span] lo = min([0.0, *present]) @@ -97,11 +118,13 @@ def timeline_rows( cell), scaled onto :func:`chart_span`'s zero-folded range: the series' peak fills the space above the baseline, its floor the space below, and any non-zero reading lights at least one dot so a lone packet never vanishes. - ``None`` (no reading) and ``0`` alike draw only the faint zero baseline. + ``None`` (no reading) and ``0`` alike draw only the faint zero baseline; + :data:`GAP` draws nothing at all, breaking the baseline into a clean notch. Args: values: Per-slot readings, oldest first (the rightmost is "now"). An odd count is padded with one silent column so whole cells always render. + A :data:`GAP` slot renders fully blank (bar and baseline both). rows: How many braille rows tall the chart is (four dot rows each). span: A wider range to scale against (see :func:`chart_span`), so several charts — or a chart and its caption — can share one scale. @@ -125,7 +148,9 @@ def timeline_rows( bars: list[Optional[tuple[int, int]]] = [] for value in values: - if value is None or value == 0: + # GAP must short-circuit ahead of the numeric tests — it has no magnitude, + # so ``value == 0`` / ``value > 0`` would misfire (or raise) on the sentinel. + if value is GAP or value is None or value == 0: bars.append(None) elif value > 0: height = max(1, round(value / hi * up)) @@ -415,12 +440,15 @@ def _assemble( Every bar span includes the baseline row by construction, so a half-silent cell keeps the zero line continuous inside the lit character; a fully silent cell shows just the faint baseline dots on whichever row holds them; anything - else stays blank braille to keep the grid monospace. + else stays blank braille to keep the grid monospace. The baseline is decided + per *dot column*, not per cell, so a :data:`GAP` column can go fully blank — + breaking the zero line into a notch — while its cell-mate keeps its own. Args: bars: Per-column inclusive dot-row spans (``None`` = no bar). values: The readings behind the columns, aligned with ``bars`` (fed to a - callable ``style`` two at a time, per cell). + callable ``style`` two at a time, per cell); a :data:`GAP` reading + suppresses that column's baseline so the notch reaches the axis. rows: Chart height in braille rows. base: The zero baseline's dot row. style: Fixed style, or per-cell callable (see :data:`CellStyle`). @@ -442,20 +470,26 @@ def _assemble( base_bit_right = _RIGHT_BITS[base - floor] if floor <= base <= floor + 3 else 0 line = Text() for i in range(0, len(bars), 2): + # A GAP column carries no baseline, so the zero line breaks there and the + # notch runs clean from the top edge down to the axis border. + bl = 0 if values[i] is GAP else base_bit_left + br = 0 if values[i + 1] is GAP else base_bit_right left = _column_bits(bars[i], floor, _LEFT_BITS) right = _column_bits(bars[i + 1], floor, _RIGHT_BITS) if left or right: - mask = left | right | base_bit_left | base_bit_right + mask = left | right | bl | br 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] + present = [ + v for v in values[i : i + 2] if v is not None and v is not GAP + ] cell_style = style(present) if present else baseline_style else: cell_style = style line.append(chr(0x2800 | mask), style=cell_style) - elif base_bit_left: - line.append(chr(0x2800 | base_bit_left | base_bit_right), style=baseline_style) + elif bl or br: + line.append(chr(0x2800 | bl | br), style=baseline_style) else: line.append(chr(0x2800)) lines.append(line) diff --git a/meshterm/ui/timemachine_screen.py b/meshterm/ui/timemachine_screen.py index 6f83d00..9730a6d 100644 --- a/meshterm/ui/timemachine_screen.py +++ b/meshterm/ui/timemachine_screen.py @@ -30,7 +30,7 @@ from rich.console import Group, RenderableType from rich.text import Text from ..core.models import utcnow -from .braillechart import axis_caption, axis_chart, chart_span, timeline_rows +from .braillechart import GAP, axis_caption, axis_chart, chart_span, timeline_rows from .theme import snr_style from .tui.render import render_lines from .tui.screen import Screen @@ -319,7 +319,7 @@ def _day_axis(shown: list) -> Callable[[float], str]: return label_at -def _day_columns(values: list[int], chars: int) -> list[Optional[int]]: +def _day_columns(values: list[int], chars: int) -> list: """Stretch per-day counts into day-wide bars that fill the chart's width exactly. One dot column per day leaves a short history as a sliver in a wide terminal — @@ -330,23 +330,23 @@ def _day_columns(values: list[int], chars: int) -> list[Optional[int]]: remainder instead, leaving the bars short of the axis border and caption sized for the full width (misreading as the whole chart sitting shifted left). - A day wide enough to span more than one character has its very first dot - column left blank — the left half of its leading braille cell — so same-height - neighbours still read as separate bars instead of fusing into one solid block. - A history deeper than the chart is wide falls back to one dot column per day - (no room for a full character each, so no notch), the dot-column total still - landing exactly on ``2 × chars``. + A day wide enough to span more than one character opens with a + :data:`~meshterm.ui.braillechart.GAP` dot column — the left half of its leading + braille cell, blank clean down to the axis — so same-height neighbours read as + separate bars instead of fusing into one solid block. A history deeper than the + chart is wide falls back to one dot column per day (no room for a full character + each, so no notch), the dot-column total still landing exactly on ``2 × chars``. Args: values: Per-day counts, oldest first. chars: The chart's width in character cells. Returns: - Exactly ``2 × chars`` dot-column readings, oldest first (``None`` marks a - day-boundary notch). + Exactly ``2 × chars`` dot-column readings, oldest first (a + :data:`~meshterm.ui.braillechart.GAP` marks a day-boundary notch). """ n = max(1, len(values)) - out: list[Optional[int]] = [] + out: list = [] if n <= chars: # Each day spans at least one whole character: split in character units so # every day's block starts on an even dot-column index (a fresh cell), @@ -355,7 +355,7 @@ def _day_columns(values: list[int], chars: int) -> list[Optional[int]]: for i, value in enumerate(values): width_chars = base + (1 if i < extra else 0) if width_chars > 1: - out.append(None) + out.append(GAP) out.extend([value] * (width_chars * 2 - 1)) else: out.extend([value] * (width_chars * 2)) diff --git a/tests/test_braillechart.py b/tests/test_braillechart.py index 563323e..115244c 100644 --- a/tests/test_braillechart.py +++ b/tests/test_braillechart.py @@ -7,6 +7,7 @@ the rendered braille bitmasks. from __future__ import annotations from meshterm.ui.braillechart import ( + GAP, activity_sparkline, axis_chart, chart_span, @@ -84,6 +85,37 @@ def test_timeline_pads_an_odd_tail_column() -> None: assert rows[0].plain[0] == _ch(_L[4], _R[1]) # full bar + the padded column's floor +# --- timeline_rows: GAP (a hard break in the baseline) -------------------------------- + + +def test_timeline_gap_breaks_the_baseline_but_zero_keeps_it() -> None: + """A GAP column draws nothing — not even the zero line — while a 0 keeps its dot. + + The cell pairs a GAP (left) with a plain 0 (right): the left column is fully + blank down to the floor, the right column still shows its faint baseline dot — + the distinction that lets a day-boundary notch reach the axis while an empty + day stays a grey flatline. + """ + rows = timeline_rows([GAP, 0], rows=1) + assert rows[0].plain[0] == _ch(_R[1]) # only the right (0) column's baseline dot + assert rows[0].spans and rows[0].spans[0].style == "faint" + + +def test_timeline_gap_column_stays_blank_beside_a_full_bar() -> None: + """A GAP paired with a tall bar keeps its own column blank to the axis.""" + rows = timeline_rows([GAP, 4], rows=1) # left GAP, right full-height bar + # Right column carries the whole bar; the left (GAP) column lights no dot at all, + # not even the bottom baseline the bar's cell would otherwise share. + assert rows[0].plain[0] == _ch(_R[4]) + assert not (ord(rows[0].plain[0]) & 0x40) # no bottom-left dot + + +def test_chart_span_ignores_gap_columns() -> None: + """GAP carries no magnitude, so it never widens the span.""" + assert chart_span([GAP, 3, 7]) == (0.0, 7.0) + assert chart_span([GAP, GAP]) == (0.0, 0.0) + + # --- timeline_rows: negative and mixed series ------------------------------------------ diff --git a/tests/test_timemachine.py b/tests/test_timemachine.py index daead1b..7d911c9 100644 --- a/tests/test_timemachine.py +++ b/tests/test_timemachine.py @@ -13,6 +13,7 @@ from types import SimpleNamespace from meshterm.core.models import Observation, utcnow from meshterm.persistence.repository import Repository +from meshterm.ui.braillechart import GAP from meshterm.ui.timemachine_screen import ( TimeMachineScreen, _day_columns, @@ -168,9 +169,9 @@ def test_day_columns_always_hits_the_exact_width() -> None: def test_day_columns_notches_the_first_dot_of_a_multi_character_day() -> None: - """A day wide enough to span several characters opens with a blank dot column.""" + """A day wide enough to span several characters opens with a GAP dot column.""" cols = _day_columns([2, 8, 4], 12) # 3 days, 12 chars -> 4 chars (8 dots) each - assert cols[0] is None and cols[8] is None and cols[16] is None + assert cols[0] is GAP and cols[8] is GAP and cols[16] is GAP assert cols[1:8] == [2] * 7 assert cols[9:16] == [8] * 7 assert cols[17:24] == [4] * 7 @@ -179,7 +180,7 @@ def test_day_columns_notches_the_first_dot_of_a_multi_character_day() -> None: def test_day_columns_skips_the_notch_for_a_single_character_day() -> None: """A day exactly one character wide stays fully lit — nothing to space apart.""" cols = _day_columns([5, 9], 2) # 2 days, 2 chars -> exactly 1 char (2 dots) each - assert None not in cols + assert GAP not in cols assert cols == [5, 5, 9, 9] @@ -187,7 +188,28 @@ def test_day_columns_skips_notches_when_days_outnumber_characters() -> None: """More days than character columns: every day is sub-character, no notches.""" cols = _day_columns(list(range(50)), 30) # 50 days into 30 chars (<=60 dots) assert len(cols) == 60 - assert None not in cols + assert GAP not in cols + + +def test_day_chart_notch_reaches_the_axis_but_a_zero_day_keeps_its_baseline() -> None: + """The notch column is blank down to the axis; a zero-value day stays a grey line. + + Tall day, empty day, tall day at 4 chars each: the leading cell of each tall day + shows its right dot column only (the notch's left column blank all the way down), + and the empty day draws the faint zero baseline across its own columns. + """ + from meshterm.ui.braillechart import timeline_rows + + cols = _day_columns([8, 0, 8], 12) # -> 4 chars (8 dot cols) per day + bottom = timeline_rows(cols, rows=2)[-1] + # Leading cell of the first tall day: right column filled, left (notch) blank — + # no bottom-left dot, so the gap runs clean to the axis border below. + assert not (ord(bottom.plain[0]) & 0x40) # the notch's bottom-left dot is unlit + # The empty middle day (cells 4..7) draws the faint zero baseline, in grey. + zero_cells = bottom.plain[4:8] + assert all(ord(c) & 0x80 for c in zero_cells) # each keeps its right baseline dot + zero_spans = [s for s in bottom.spans if 4 <= s.start < 8] + assert zero_spans and all(s.style == "faint" for s in zero_spans) # --- the pages --------------------------------------------------------------------------