Chart notches break the baseline cleanly to the axis

The day-boundary notch left its bottom dot lit: the zero baseline is
drawn per braille cell (both dot columns), so it filled in under the
notch's blank column, and the notch connected to the axis instead of
extending down to it. Worse, when the neighbouring bar was tall the
notch's bottom dot took the bar's colour, not even the faint grey.

Introduce a GAP sentinel in braillechart: a timeline column that
renders fully blank — no bar and no baseline — the one deliberate
break in the "grey is zero" flatline. _assemble now decides the
baseline per dot column rather than per cell, so a GAP column goes
blank clean to the axis while its cell-mate keeps its own zero line.
The Time Machine's day charts mark their notches with GAP instead of
None.

A genuine zero-value day still draws its faint grey baseline as
before — only the notch column breaks it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-11 21:10:00 -04:00
1 parent 4e8325af08
commit fa340bec03
4 files changed
+115 -27

No files matched your search

+45 -11
View File
@@ -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)
+12 -12
View File
@@ -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))
+32
View File
@@ -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 ------------------------------------------
+26 -4
View File
@@ -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 --------------------------------------------------------------------------