diff --git a/meshterm/ui/braillechart.py b/meshterm/ui/braillechart.py index a702ff0..185b914 100644 --- a/meshterm/ui/braillechart.py +++ b/meshterm/ui/braillechart.py @@ -326,20 +326,31 @@ def axis_caption( def y_axis_labels(peak: float, rows: int, *, lo: float = 0.0) -> list[str]: - """Each chart row's top-edge value, top row first, dupes and zeros blanked. + """Each chart row's ticked-dot value, top row first, dupes and zeros blanked. - The top row always reads the peak; lower rows read the proportional values at - their upper edges, but a mark that would repeat the one above (a low peak makes - neighbouring rows round to the same value) or read zero is left blank, so the + The gutter's ``┤`` tick crosses a braille row at the third dot up from the + row's bottom, so each mark quotes the value of a bar peaking *at that dot* — + the reading the tick visibly points at, not the row's top edge — using the + same zero-folded scaling :func:`timeline_rows` draws bars with (baseline row + and all). A mark that would repeat the one above (a low peak makes + neighbouring dots round to the same value) or read zero is left blank, so the scale never shows the same number twice. A signed chart passes its floor as - ``lo`` (``< 0``): the marks then run linearly from ``peak`` at the top down - through the grey zero line to ``lo`` at the bottom, so the gutter quotes both - extremes (an SNR band's ``+5 … −10 dB``) instead of only the positive peak. + ``lo`` (``< 0``): marks above the grey zero line quote rise heights, marks + below it hang depths, so the gutter shows both signs of a zero-crossing + series (an SNR band's ``+5 … −10 dB``) instead of only the positive peak. """ + total = rows * 4 + base = _baseline_row(lo, peak, total) labels: list[str] = [] seen: set[int] = set() for i in range(rows): - value = round(peak - (peak - lo) * i / rows) + dot = (rows - 1 - i) * 4 + 2 # the dot row this row's ┤ tick crosses + if dot > base: + value = round(peak * (dot - base + 1) / (total - base)) + elif dot < base: + value = round(lo * (base - dot + 1) / (base + 1)) + else: + value = 0 # the tick sits on the zero baseline itself if peak != lo and value and value not in seen: labels.append(str(value)) seen.add(value) @@ -425,7 +436,10 @@ def axis_chart( Args: chart_rows: The chart's rows, as returned by :func:`timeline_rows`. - peak: The value the top row's mark reads (the chart's tallest bar). + peak: The chart's full-scale ceiling (its tallest bar's value); each + row's gutter mark quotes the value at the dot its tick points at + (see :func:`y_axis_labels`), so the peak itself sizes the gutter + but is not necessarily printed. chars: The chart's width in character cells. label_at: Maps a position fraction to the x-axis caption at that point (a continuous chart); ignored when ``ticks`` is given. diff --git a/meshterm/ui/timemachine_screen.py b/meshterm/ui/timemachine_screen.py index 2b93e86..148e397 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 GAP, axis_chart, chart_span, timeline_rows +from .braillechart import GAP, _TICK_GAP, axis_chart, chart_span, timeline_rows from .menus import fit_cells, section_heading from .theme import snr_style from .tui.render import render_lines @@ -314,12 +314,35 @@ def _node_sections( # --- the mesh page ----------------------------------------------------------------------- +def _day_spans(days: int, chars: int) -> list[tuple[int, int]]: + """Each day's ``(start, width)`` share of the chart's ``2 × chars`` dot columns. + + The one even split behind both the bars (:func:`_day_columns`) and their axis + ticks (:func:`_day_centers`): day ``i`` runs from dot ``⌊i·D/n⌋`` to + ``⌊(i+1)·D/n⌋`` (``D = 2 × chars``), so no two days differ by more than one + dot column and the wider days interleave evenly among the narrower ones + instead of pooling at either end. The widths always sum to exactly ``D``, + keeping the bars flush with the axis border and caption sized for them. + + Args: + days: How many day bars the chart draws. + chars: The chart's width in character cells. + + Returns: + One ``(start dot, width in dots)`` per day, oldest first. + """ + dots = chars * 2 + n = max(1, days) + edges = [i * dots // n for i in range(n + 1)] + return [(edges[i], edges[i + 1] - edges[i]) for i in range(n)] + + def _day_centers(days: int, chars: int) -> list[int]: """The chart cell each day's bar is centred on, mirroring :func:`_day_columns`. - Replays the same even split ``_day_columns`` uses to spread ``days`` bars across - ``2 × chars`` dot columns, so a tick placed at ``centers[i]`` lands under day - ``i``'s bar rather than at an arbitrary fraction of the axis. + Reads the same :func:`_day_spans` split ``_day_columns`` draws with, so a tick + placed at ``centers[i]`` lands under day ``i``'s bar rather than at an + arbitrary fraction of the axis. Args: days: How many day bars the chart draws. @@ -328,25 +351,24 @@ def _day_centers(days: int, chars: int) -> list[int]: Returns: One centre cell (``0 .. chars - 1``) per day, oldest first. """ - n = max(1, days) - centers: list[int] = [] - dot = 0 # running dot-column offset, two per character cell - base, extra = divmod(chars, n) if n <= chars else divmod(chars * 2, n) - for i in range(n): - span = (base + (1 if i < extra else 0)) * (2 if n <= chars else 1) - centers.append(min(chars - 1, (dot + span // 2) // 2)) - dot += span - return centers + return [ + min(chars - 1, (start + width // 2) // 2) + for start, width in _day_spans(days, chars) + ] def _day_ticks(shown: list, chars: int) -> list[tuple[int, str]]: - """``(cell, label)`` axis ticks under the day bars: short dates, thinned to fit. + """``(cell, label)`` axis ticks under the day bars: as many short dates as fit. - One tick per day where they all fit, else an evenly spaced subset keeping both - ends; each label sits under its own bar (see :func:`_day_centers`). Dates are - kept compact — the month is shown only on the first tick and whenever it rolls - over, so most ticks read as a bare day number — and the newest bar reads - ``today`` when it is, mirroring the node page's closing ``now``. + Ticks are claimed in priority order — the newest day first, the oldest day + second, then the rest right to left — each label centred under its own bar + (see :func:`_day_centers`) and keeping at least two blank cells from every + label already placed, so the axis packs as dense as it can and thins from the + middle-left outward when room runs out, never losing either end. Dates stay + compact: the month is written only on the oldest day and on the first of a + month (the calendar anchors), the rest read as bare day numbers, and the + newest bar reads ``today`` when it is, mirroring the node page's closing + ``now``. Args: shown: The charted days, oldest first, each ``(iso_date, ...)``. @@ -358,31 +380,36 @@ def _day_ticks(shown: list, chars: int) -> list[tuple[int, str]]: n = len(shown) centers = _day_centers(n, chars) today = utcnow().strftime("%Y-%m-%d") - # A dated label is at most "Jul 12" (6 cells); keep a couple of cells between them. - fit = max(2, chars // 8) - if n <= fit: - picks = list(range(n)) - else: - picks = sorted({round(i * (n - 1) / (fit - 1)) for i in range(fit)}) - ticks: list[tuple[int, str]] = [] - prev_month: Optional[str] = None - for i in picks: + + def label_for(i: int) -> str: iso = shown[i][0] try: day = datetime.strptime(iso, "%Y-%m-%d") except ValueError: - ticks.append((centers[i], iso)) - continue - month = day.strftime("%b") + return iso if i == n - 1 and iso == today: - label = "today" - elif month != prev_month: - label = f"{month} {day.day}" - else: - label = str(day.day) - prev_month = month - ticks.append((centers[i], label)) - return ticks + return "today" + if i == 0 or day.day == 1: + return f"{day:%b} {day.day}" + return str(day.day) + + # Claimed [start, end) label spans — the same centring and spacing arithmetic + # _tick_axis renders with, so everything placed here survives its backstop. + taken: list[tuple[int, int]] = [] + + def claim(i: int) -> Optional[tuple[int, str]]: + label = label_for(i) + start = max(0, min(chars - len(label), centers[i] - len(label) // 2)) + end = start + len(label) + if end > chars or any( + start < e + _TICK_GAP and s < end + _TICK_GAP for s, e in taken + ): + return None + taken.append((start, end)) + return centers[i], label + + order = [n - 1, *([0] if n > 1 else []), *range(n - 2, 0, -1)] + return sorted(tick for i in order if (tick := claim(i)) is not None) def _fill_days( @@ -427,18 +454,19 @@ def _day_columns(values: list[int], chars: int) -> list: One dot column per day leaves a short history as a sliver in a wide terminal — beneath how every other MeshTerm chart spends its width — so each day repeats - over a share of the chart's columns instead. The share is an even split with - the one-off remainder spread across the oldest days, so the total always lands - on exactly ``2 × chars`` dot columns: a plain floor division drops the - 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). + over its :func:`_day_spans` share of the chart's dot columns instead: an even + split whose widths never differ by more than one dot and whose total always + lands on exactly ``2 × chars`` (a plain floor division drops the remainder, + 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 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``. + When every day is at least three dots wide, each opens with a + :data:`~meshterm.ui.braillechart.GAP` dot column — its first dot, blank clean + down to the axis — so same-height neighbours read as separate bars instead of + fusing into one solid block. The oldest day keeps its notch too: it doubles as + the bar's one-dot clearance off the axis border, paid for inside the day's own + span. A deeper history (days of one or two dots) drops the notch chart-wide — + such a bar has no dot to spare — and the days simply abut. Args: values: Per-day counts, oldest first. @@ -448,26 +476,15 @@ def _day_columns(values: list[int], chars: int) -> list: Exactly ``2 × chars`` dot-column readings, oldest first (a :data:`~meshterm.ui.braillechart.GAP` marks a day-boundary notch). """ - n = max(1, len(values)) + spans = _day_spans(len(values), chars) + notch = min(width for _start, width in spans) >= 3 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), - # which is what makes "blank the leading cell's left half" well-defined. - base, extra = divmod(chars, n) - for i, value in enumerate(values): - width_chars = base + (1 if i < extra else 0) - if width_chars > 1: - out.append(GAP) - out.extend([value] * (width_chars * 2 - 1)) - else: - out.extend([value] * (width_chars * 2)) - else: - # More days than characters: no day gets a whole one, so no notch is drawn - # (nothing to space apart) — just split the dot columns themselves. - base, extra = divmod(chars * 2, n) - for i, value in enumerate(values): - out.extend([value] * (base + (1 if i < extra else 0))) + for value, (_start, width) in zip(values, spans): + if notch: + out.append(GAP) + out.extend([value] * (width - 1)) + else: + out.extend([value] * width) return out diff --git a/tests/test_braillechart.py b/tests/test_braillechart.py index 6520377..cfa3067 100644 --- a/tests/test_braillechart.py +++ b/tests/test_braillechart.py @@ -195,9 +195,14 @@ def test_y_axis_labels_blanks_a_silent_chart() -> None: assert y_axis_labels(0, 3) == ["", "", ""] -def test_y_axis_labels_top_row_reads_the_peak() -> None: - """The top row always reads the peak; lower rows are proportional.""" - assert y_axis_labels(10, 2) == ["10", "5"] +def test_y_axis_labels_marks_quote_the_ticked_dot() -> None: + """Each mark reads the value at its row's third dot — where the ┤ tick points. + + peak 10 over 2 rows (8 dots): the top row's tick crosses dot 6 (a 7-dot bar, + 10·7/8 → 9), the bottom row's dot 2 (a 3-dot bar, 10·3/8 → 4) — not the rows' + top-edge values (10 and 5), which sit a dot above where the glyph points. + """ + assert y_axis_labels(10, 2) == ["9", "4"] def test_y_axis_labels_blanks_a_repeated_mark() -> None: @@ -210,25 +215,26 @@ def test_axis_chart_mirrors_marks_on_both_gutters() -> None: rows = timeline_rows([9] * 8, rows=2) out = axis_chart(rows, 9, 4, lambda f: "now" if f >= 1.0 else "old") assert len(out) == 4 # 2 chart rows + bottom border + caption - assert out[0].plain == "9 ┤⣿⣿⣿⣿├ 9" - assert out[1].plain == "4 ┤⣿⣿⣿⣿├ 4" + assert out[0].plain == "8 ┤⣿⣿⣿⣿├ 8" # ticked-dot values (peak 9 sizes the gutter) + assert out[1].plain == "3 ┤⣿⣿⣿⣿├ 3" assert out[2].plain.startswith(" └") and out[2].plain.endswith("┘") def test_y_axis_labels_signed_span_quotes_both_extremes() -> None: - """A signed chart's marks run from the peak down through zero toward the floor.""" - # rows=3 over +8..-4: top edge 8, middle 4, then 0 (blanked — it is the grey line). - assert y_axis_labels(8, 3, lo=-4) == ["8", "4", ""] - # A wider signed span surfaces a negative mark on the bottom row. - assert y_axis_labels(6, 3, lo=-6) == ["6", "2", "-2"] + """A signed chart's marks quote rise heights above zero and hang depths below.""" + # rows=3 over +8..-4 (baseline on dot 4): the top tick reads a 7-dot rise (7), + # the middle a 3-dot rise (3), the bottom a 3-deep hang below zero (−2). + assert y_axis_labels(8, 3, lo=-4) == ["7", "3", "-2"] + # A symmetric span pins the middle tick onto the baseline itself: blanked zero. + assert y_axis_labels(6, 3, lo=-6) == ["5", "", "-4"] def test_axis_chart_floor_frames_a_signed_chart_and_widens_the_gutter() -> None: """A signed floor draws negative marks and sizes the gutter for the widest one.""" rows = timeline_rows([5, -5], rows=3, span=(-5, 5)) out = axis_chart(rows, 5, 1, lambda f: "x", floor=-5) - assert out[0].plain.startswith(" 5 ┤") # peak on top, gutter widened to two cells - assert out[2].plain.startswith("-2 ┤") # a negative mark near the floor + assert out[0].plain.startswith(" 4 ┤") # gutter widened to two cells by the floor + assert out[2].plain.startswith("-4 ┤") # a negative mark near the floor def test_axis_chart_honours_a_shared_label_width() -> None: @@ -239,7 +245,7 @@ def test_axis_chart_honours_a_shared_label_width() -> None: """ rows = timeline_rows([9] * 8, rows=1) out = axis_chart(rows, 9, 4, lambda f: "x", label_w=3) - assert out[0].plain.startswith(" 9 ┤") + assert out[0].plain.startswith(" 7 ┤") # --- axis_chart column ticks ---------------------------------------------------------- diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 4c29b8d..2dcd040 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -75,12 +75,16 @@ def test_dashboard_renders_all_four_sections() -> None: def test_dashboard_activity_chart_reads_newest_right_with_mirrored_scale() -> None: - """'now' anchors the right edge and the peak count marks both gutters.""" + """'now' anchors the right edge and the scale marks mirror on both gutters. + + Peak 9 over 3 rows (12 dots): the top gutter's ┤ tick crosses its row's third + dot (an 11-dot bar → 9·11/12 ≈ 8), so the mark reads 8, not the peak itself. + """ 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") + assert top.strip().startswith("8 ┤") + assert top.rstrip().endswith("├ 8") caption = next(line for line in lines if "now" in line) assert caption.index("−") < caption.index("now") # oldest left, newest right # The lone newest-minute burst draws against the chart's right gutter, and the diff --git a/tests/test_timemachine.py b/tests/test_timemachine.py index 53473eb..e693b1c 100644 --- a/tests/test_timemachine.py +++ b/tests/test_timemachine.py @@ -224,21 +224,66 @@ def test_day_ticks_shorten_dates_and_align_to_bars() -> None: def test_day_ticks_reprint_the_month_on_a_rollover() -> None: - """The month reappears the first time a new one starts, so a boundary reads clearly.""" + """The first of a month always carries its month name, so a boundary reads clearly.""" days = [(iso, 1, 1) for iso in ("2026-06-29", "2026-06-30", "2026-07-01", "2026-07-02")] labels = [label for _, label in _day_ticks(days, 60)] assert labels == ["Jun 29", "30", "Jul 1", "2"] -def test_day_ticks_thin_to_fit_keeping_both_ends() -> None: - """More days than labels fit: an evenly spaced subset is kept, first and last included.""" +def test_day_ticks_prioritise_the_newest_then_the_oldest_day() -> None: + """With room for almost nothing, the newest day wins, then the oldest. + + A chart squeezed to the minimum width still labels its "now" edge first and + its far edge second; whatever interior days fit are claimed right to left, so + the freshest history stays the best annotated. + """ days = [(f"2026-06-{d:02d}", 1, 1) for d in range(1, 31)] # 30 days - chars = 40 # ~5 dated labels fit (chars // 8) + chars = 20 ticks = _day_ticks(days, chars) centers = _day_centers(len(days), chars) - assert 2 <= len(ticks) <= 6 cols = [cell for cell, _ in ticks] - assert cols[0] == centers[0] and cols[-1] == centers[-1] # both ends survive the thinning + assert centers[-1] in cols # the newest day always keeps its tick + assert centers[0] in cols # …and the oldest is claimed right after it + + +def test_day_columns_widths_differ_by_at_most_one_dot_and_interleave() -> None: + """An uneven split spreads the wider days through the chart, not to one side. + + 13 days over 30 chars (60 dots) can't divide evenly: days get 4 or 5 dots. + The widths must never differ by more than one dot column, and the wide days + must mix with the narrow ones instead of pooling at either end (the old + char-unit split put every wide day on the left, every narrow one on the right). + """ + cols = _day_columns([1] * 13, 30) + assert len(cols) == 60 + starts = [i for i, c in enumerate(cols) if c is GAP] # one notch opens each day + widths = [b - a for a, b in zip(starts, [*starts[1:], len(cols)])] + assert len(widths) == 13 and set(widths) == {4, 5} + first_wide = widths.index(5) + last_wide = len(widths) - 1 - widths[::-1].index(5) + assert any(w == 4 for w in widths[first_wide:last_wide]) # narrow days sit between wide ones + + +def test_day_ticks_pack_the_axis_keeping_both_ends() -> None: + """More days than labels fit: the axis packs what it can, ends always included. + + Ticks are claimed newest-first, then oldest, then right to left, each keeping + two blank cells from its neighbours — so a crowded axis stays dense (well past + the old evenly-thinned handful) and any dropped days come from the interior. + """ + days = [(f"2026-06-{d:02d}", 1, 1) for d in range(1, 31)] # 30 days + chars = 40 + ticks = _day_ticks(days, chars) + centers = _day_centers(len(days), chars) + cols = [cell for cell, _ in ticks] + assert cols[0] == centers[0] and cols[-1] == centers[-1] # both ends survive + assert len(ticks) >= 8 # bare day numbers pack far denser than dated labels + # Every placed label keeps at least two blank cells from the one before it. + spans = [] + for cell, label in ticks: + start = max(0, min(chars - len(label), cell - len(label) // 2)) + spans.append((start, start + len(label))) + assert all(b_start >= a_end + 2 for (_, a_end), (b_start, _) in zip(spans, spans[1:])) def test_day_columns_notches_the_first_dot_of_a_multi_character_day() -> None: