From fb2ead3cc36ced502492718f49471e75c8e51846 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sun, 4 Oct 2026 02:59:31 -0400 Subject: [PATCH] Charts keep a three-cell y-axis gutter; the Rhythm drops its slice width when narrow A chart's y-axis labels got exactly the cells their widest mark needed, so the gutter grew under the reader as a busy minute took the peak from 9 to 10 to 100, sliding the whole chart sideways. Every gutter now gets at least three cells (AXIS_LABEL_MIN), which hold every mark below a thousand and every compacted one, so it stays put. The Time Machine's Rhythm heading ended on its slice width ("20-min slices"), which on the PicoCalc's and Cardputer's 53 columns wrapped the heading onto a second row. Where the whole heading would wrap, the slice width goes; the axis under the chart already marks the day's hours. Co-Authored-By: Claude Opus 5.5 (1M context) --- meshterm/ui/braillechart.py | 17 +++++++++--- meshterm/ui/timemachine_screen.py | 19 ++++++++----- tests/test_braillechart.py | 45 ++++++++++++++++++++++--------- tests/test_dashboard.py | 12 ++++++--- tests/test_timemachine.py | 10 +++++++ 5 files changed, 77 insertions(+), 26 deletions(-) diff --git a/meshterm/ui/braillechart.py b/meshterm/ui/braillechart.py index 04cc753..17addcb 100644 --- a/meshterm/ui/braillechart.py +++ b/meshterm/ui/braillechart.py @@ -473,15 +473,24 @@ def compact_label(value: float) -> str: return f"{round(value / 1_000_000)}M" +#: The fewest cells a chart's y-axis labels are given, however small its peak (JP, +#: 2026-10-04). Sized to the widest mark alone, the gutter grew as a busy minute pushed the +#: peak from ``9`` to ``10`` to ``100``, sliding the whole chart sideways under the reader; +#: three cells hold every mark :func:`compact_label` prints below a thousand (``563``) and +#: every compacted one (``1k``, ``-12``), so the gutter stays put. +AXIS_LABEL_MIN = 3 + + def axis_label_w(peak: float, rows: int, *, lo: float = 0.0) -> int: - """The gutter width a chart of this scale actually needs: its widest printed mark. + """The gutter width a chart of this scale needs: its widest printed mark, at least 3. Sizing from the compacted *peak* alone under-measures: a ``1.5k`` peak prints as the two-cell ``1k`` while a lower tick can still land at ``563`` — three cells. Callers sharing one gutter across stacked charts take the max of this over each chart's - scale (and the default :func:`axis_chart` gutter is computed the same way). + scale (and the default :func:`axis_chart` gutter is computed the same way). Never + narrower than :data:`AXIS_LABEL_MIN`. """ - return max([1, *(len(mark) for mark in y_axis_labels(peak, rows, lo=lo))]) + return max([AXIS_LABEL_MIN, *(len(mark) for mark in y_axis_labels(peak, rows, lo=lo))]) def y_axis_labels(peak: float, rows: int, *, lo: float = 0.0) -> list[str]: @@ -624,7 +633,7 @@ def axis_chart( bottom border, and the caption. """ marks = y_axis_labels(peak, len(chart_rows), lo=floor) - label_w = label_w or max([1, *(len(mark) for mark in marks)]) + label_w = label_w or max([AXIS_LABEL_MIN, *(len(mark) for mark in marks)]) out: list[Text] = [] for mark, row in zip(marks, chart_rows, strict=True): line = Text(f"{mark:>{label_w}} " + ("┤" if mark else "│"), style=style) diff --git a/meshterm/ui/timemachine_screen.py b/meshterm/ui/timemachine_screen.py index e50f4e4..c809ad5 100644 --- a/meshterm/ui/timemachine_screen.py +++ b/meshterm/ui/timemachine_screen.py @@ -135,6 +135,17 @@ def _slice_note(minutes: int) -> str: return "1 h slices" if minutes >= 60 else f"{minutes}-min slices" +def _rhythm_heading(what: str, minutes: int, width: int) -> Text: + """The Rhythm heading: what it counts, then its slice width where the line has room. + + The slice width is the part the reader needs least — the axis under the chart marks the + day's hours — so where the whole heading would wrap onto a second line (the PicoCalc's + and the Cardputer's 53 columns, JP 2026-10-04) it goes, and the rest keeps one row. + """ + heading = body_heading("Rhythm", f"{what} · {_slice_note(minutes)}") + return heading if heading.cell_len <= width else body_heading("Rhythm", what) + + def _rhythm_slots(stamps: list[datetime], minutes: int) -> list[int]: """Fold timestamps into their local time-of-day slice at ``minutes`` per slot.""" slots = [0] * (24 * 60 // minutes) @@ -434,9 +445,7 @@ def _node_sections( ) out.append(Text()) - out.append( - body_heading("Rhythm", f"receptions by local time of day · {_slice_note(slice_minutes)}") - ) + out.append(_rhythm_heading("receptions by local time of day", slice_minutes, width)) out.extend( axis_chart( timeline_rows(slots, rows=_CHART_ROWS), @@ -557,9 +566,7 @@ def _self_sections(ctx: AppContext, window: timedelta | None, width: int) -> lis ) out.append(Text()) - out.append( - body_heading("Rhythm", f"transmissions by local time of day · {_slice_note(slice_minutes)}") - ) + out.append(_rhythm_heading("transmissions by local time of day", slice_minutes, width)) out.extend( axis_chart( timeline_rows(slots, rows=_CHART_ROWS), diff --git a/tests/test_braillechart.py b/tests/test_braillechart.py index 296e39a..6c3c7b0 100644 --- a/tests/test_braillechart.py +++ b/tests/test_braillechart.py @@ -270,15 +270,15 @@ def test_axis_chart_mirrors_marks_where_the_platform_affords_them() -> 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 == "8 ┤⣿⣿⣿⣿├ 8" # ticked-dot values, mirrored on the desktop - assert out[1].plain == "3 ┤⣿⣿⣿⣿├ 3" - assert out[2].plain.startswith(" └") and out[2].plain.endswith("┘") + assert out[0].plain == " 8 ┤⣿⣿⣿⣿├ 8" # ticked-dot values, mirrored on the desktop + assert out[1].plain == " 3 ┤⣿⣿⣿⣿├ 3" # (in the 3-cell minimum gutter) + assert out[2].plain.startswith(" └") and out[2].plain.endswith("┘") assert axis_chrome(1) == 2 * (1 + 2) # what a caller must budget beside the cells set_platform(PICOCALC) tight = axis_chart(timeline_rows([9] * 8, rows=2), 9, 4, lambda f: "x") - assert tight[0].plain == "8 ┤⣿⣿⣿⣿├" # the tick survives; the label's cells don't - assert tight[1].plain == "3 ┤⣿⣿⣿⣿├" + assert tight[0].plain == " 8 ┤⣿⣿⣿⣿├" # the tick survives; the label's cells don't + assert tight[1].plain == " 3 ┤⣿⣿⣿⣿├" assert axis_chrome(1) == 1 + 3 @@ -311,11 +311,30 @@ def test_y_axis_labels_signed_span_quotes_both_extremes() -> None: 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(" 4 ┤") # gutter widened to two cells by the floor - assert out[2].plain.startswith("-4 ┤") # a negative mark near the floor + """A signed floor draws negative marks and sizes the gutter for the widest one. + + ``-107`` takes four cells, one past the three every gutter is given anyway, so the + floor is what widens it here. + """ + rows = timeline_rows([150, -150], rows=3, span=(-150, 150)) + out = axis_chart(rows, 150, 1, lambda f: "x", floor=-150) + assert out[0].plain.startswith(" 125 ┤") # gutter widened to four cells by the floor + assert out[2].plain.startswith("-107 ┤") # a negative mark near the floor + + +def test_every_gutter_is_at_least_three_cells() -> None: + """A low peak's one-digit marks still get three cells, so the chart never slides. + + Sized to the widest mark alone, the gutter grew under the reader as a busy minute + took the peak from ``9`` to ``10`` to ``100`` (JP, 2026-10-04). + """ + from meshterm.ui.braillechart import AXIS_LABEL_MIN, axis_label_w + + assert AXIS_LABEL_MIN == 3 + assert axis_label_w(2, 3) == axis_label_w(90, 3) == axis_label_w(900, 3) == 3 + assert axis_label_w(9000, 3) == 3 # compacted to "8k", still inside the three + rows = timeline_rows([2] * 8, rows=2) + assert axis_chart(rows, 2, 4, lambda f: "x")[0].plain.startswith(" 2 ┤") def test_axis_chart_honours_a_shared_label_width() -> None: @@ -336,8 +355,8 @@ def test_axis_chart_ticks_notch_the_border_and_centre_labels() -> None: """Explicit column ticks draw a ``┬`` on the border with the label centred beneath.""" rows = timeline_rows([9] * 8, rows=1) out = axis_chart(rows, 9, 4, ticks=[(0, "A"), (3, "D")]) - assert out[1].plain == " └┬──┬┘" # ticks at chart cells 0 and 3 - assert out[2].plain == " A D" # labels centred under their ticks + assert out[1].plain == " └┬──┬┘" # ticks at chart cells 0 and 3 + assert out[2].plain == " A D" # labels centred under their ticks def test_axis_chart_continuous_axis_notches_ticks_under_its_labels() -> None: @@ -346,7 +365,7 @@ def test_axis_chart_continuous_axis_notches_ticks_under_its_labels() -> None: out = axis_chart(rows, 9, 20, lambda f: "now" if f >= 1.0 else str(round(f * 100))) border, caption = out[1].plain, out[2].plain assert "┬" in border # the continuous axis is ticked, not a plain rule - assert border.startswith(" └") and border.endswith("┘") + assert border.startswith(" └") and border.endswith("┘") assert border[-2] == "┬" # the rightmost tick sits at the 'now' edge assert "now" in caption and "0" in caption diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 2ee2aef..e78c101 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -124,14 +124,20 @@ def test_dashboard_activity_chart_reads_newest_right_with_mirrored_scale() -> No # 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] + # The cells between the gutters' borders (a ticked row's are ┤ and ├). + inner = bottom[re.search("[┤│]", bottom).end() : max(map(bottom.rfind, "├│"))] + left_half = inner[: len(inner) // 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: - """The chart stretches to the render width: wider terminal, more minutes shown.""" - screen = _screen(histogram=[5] * ACTIVITY_BUCKETS) + """The chart stretches to the render width: wider terminal, more minutes shown. + + Three-digit counts fill the gutters' three cells, so the top row's marks reach both + edges and its length is the whole width. + """ + screen = _screen(histogram=[500] * ACTIVITY_BUCKETS) for width in (60, 110): lines = _stripped(screen.render_body(width)) top = next(line for line in lines if "┤" in line) diff --git a/tests/test_timemachine.py b/tests/test_timemachine.py index 7b98a10..0b22356 100644 --- a/tests/test_timemachine.py +++ b/tests/test_timemachine.py @@ -1326,6 +1326,16 @@ def test_the_scope_chip_is_the_word_alone_in_all_six_cells() -> None: assert cell_len(chip) == 6 and chip.endswith("…") and "▸" not in chip +def test_the_rhythm_heading_names_its_slices_only_where_the_line_has_room() -> None: + """On 53 columns the slice width goes rather than wrap the heading onto a second row.""" + from meshterm.ui.timemachine_screen import _rhythm_heading + + what = "receptions by local time of day" + assert _rhythm_heading(what, 20, 72).plain == f"Rhythm · {what} · 20-min slices" + assert _rhythm_heading(what, 60, 72).plain.endswith("· 1 h slices") + assert _rhythm_heading(what, 20, 53).plain == f"Rhythm · {what}" + + def test_a_page_with_no_scope_offers_no_cycle() -> None: """A node's page is its adverts, which carry no scope: no hint, no chip, no cycle.""" screen = TimeMachineScreen(