Merge branch 'main' into cardputer-zero

This commit is contained in:
Jean-Pierre Martineau committed 2026-10-04 02:59:32 -04:00
commit b884e2b468
5 files changed
+77 -26

No files matched your search

+13 -4
View File
@@ -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)
+13 -6
View File
@@ -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
View File
@@ -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
+9 -3
View File
@@ -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)
+10
View File
@@ -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(