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) <noreply@anthropic.com>
This commit is contained in:
1 parent
a8ce329758
commit
fb2ead3cc3
5 files changed
+77
-26
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
+32
-13
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user