Grow-only dialogs, full raw labels, node rhythm in 15-min slices

Carried over from the last punch-list pass: the packet viewer opts into
a grow-only floating dialog (the box ratchets to its tallest body and
blank-pads shorter ones, so paging never re-centres it); raw payload
field names are shown whole by widening the label lane instead of
clipping at eight chars; the Time Machine node rhythm matches the mesh
page — 96 fifteen-minute slices sharing the Volume gutter — and its
time axis shortens marks to bare dates on multi-day windows; Courier
and Watchtower gain a visible Back exit beside Esc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-12 03:25:15 -04:00
1 parent dfc4cb9845
commit 70efb7c694
10 files changed
+200 -56

No files matched your search

+4
View File
@@ -146,6 +146,7 @@ def _menu_items(ctx: "AppContext", entries: list[QueuedMessage]) -> list:
items.append(Separator(" empty — queued messages wait here for their moment"))
for message in waiting:
items.append(Choice(_waiting_row(ctx, message), ("msg", message.ident)))
items.append(Separator("")) # space the action off the outbox rows above it
items.append(Choice("✉ Queue a message…", _QUEUE))
if done:
@@ -154,6 +155,9 @@ def _menu_items(ctx: "AppContext", entries: list[QueuedMessage]) -> list:
for message in done[:15]:
items.append(Choice(_done_row(message), ("msg", message.ident)))
items.append(Choice("🗑 Clear finished", _CLEAR))
items.append(Separator(""))
items.append(Choice("← Back", None)) # a visible exit beside Esc
return items
+23 -18
View File
@@ -93,14 +93,6 @@ KIND_ICONS = {
}
DEFAULT_ICON = "❔"
#: The floating dialog is sized to its content, which otherwise shrinks and grows as you
#: page between a five-line advert and a telemetry frame's twenty — a jarring resize on
#: every keystroke. Padding a short packet's body up to this floor holds the box at a
#: steady minimum height so navigation stays put; a genuinely tall packet still grows past
#: it. Sized for the common overheard-packet layout (heard/from/reception/class/route/via)
#: so paging a burst of similar frames never resizes at all.
_MIN_BODY_ROWS = 10
@dataclass(slots=True)
class PacketEntry:
@@ -223,8 +215,16 @@ class PacketViewer(Screen):
packets — the same keys the opening list itself uses — Home/End jump to the
ends, PgUp/PgDn scroll a tall body, Esc closes. Opened over a single packet (a
one-entry list) the paging keys simply do nothing.
The dialog only ever grows (see
:attr:`~meshterm.ui.tui.screen.Screen.grow_only`): paging from a short packet to a
tall one enlarges the box, but paging back keeps it at that size — blank-padded
below the shorter body — rather than re-centering smaller, so the header the reader
is looking at never hops around as they page.
"""
grow_only = True
def __init__(
self,
entries: list[PacketEntry],
@@ -360,23 +360,25 @@ class PacketViewer(Screen):
# same packet, so the title's ``n/total`` and the reachable range stay current.
self._sync()
entry = self._entries[self._index]
rows = self._rows(entry)
# The label lane is one fixed width for the whole grid so wrapped values align with
# their own block (the app-wide hanging-indent rule), never with column zero. Size it
# to the widest label actually present — a raw payload field's full name included, so
# none is clipped — plus a one-cell gutter, but never so wide it leaves the value
# column under 20 cells (a pathological key then ellipsises instead of crushing it).
widest = max((len(label) for label, _ in rows), default=0)
label_w = max(10, min(widest + 1, width - 20))
grid = Table(
box=None, show_header=False, show_edge=False, pad_edge=False,
padding=(0, 0), expand=False,
)
# The label lane is fixed so wrapped values align with their own block
# (the app-wide hanging-indent rule), never with column zero.
grid.add_column(width=10, no_wrap=True)
grid.add_column(overflow="fold", max_width=max(20, width - 10))
grid.add_column(width=label_w, no_wrap=True)
grid.add_column(overflow="fold", max_width=max(20, width - label_w))
for label, value in self._rows(entry):
for label, value in rows:
grid.add_row(Text(label, style="muted"), value)
lines = render_lines(grid, width)
self._scroll_total = max(1, len(lines))
# Hold the dialog at a steady minimum height so paging between a short packet and
# a tall one doesn't resize the box out from under the reader (see _MIN_BODY_ROWS).
if len(lines) < _MIN_BODY_ROWS:
lines += [""] * (_MIN_BODY_ROWS - len(lines))
return lines
def _rows(self, entry: PacketEntry) -> list[tuple[str, RenderableType]]:
@@ -520,5 +522,8 @@ class PacketViewer(Screen):
body = str(value)
if len(body) > 200:
body = body[:199] + "…"
rows.append((f" {key[:8]}", Text(body, style="muted")))
# The label lane widens to fit the full key (see :meth:`render_body`), so the
# field name is shown whole rather than clipped; the two-space indent nests it
# visually under the "payload" heading above.
rows.append((f" {key}", Text(body, style="muted")))
return rows
+32 -17
View File
@@ -96,19 +96,24 @@ def _snr_cell_style(values: list[float]) -> str:
def _time_axis(start: datetime, end: datetime) -> Callable[[float], str]:
"""An axis labeller over a real time span: timestamps left of the closing ``now``."""
"""A compact axis labeller over a real time span, closing on ``now``.
A window wider than a couple of days reads its marks as bare dates (``Jul 4``); a
tighter one, where the calendar day barely changes, reads them as times (``18:30``)
— the same "shorten what you can" the day charts' dates use, so the volume and SNR
axes stay legible instead of repeating a full ``Jul 04 18:30`` stamp at every tick.
"""
wide = (end - start).total_seconds() > 2 * 86400
def label_at(frac: float) -> str:
if frac >= 1.0:
return "now"
return _when_label(start + (end - start) * frac)
when = (start + (end - start) * frac).astimezone()
return f"{when:%b} {when.day}" if wide else f"{when:%H:%M}"
return label_at
def _hour_axis(frac: float) -> str:
"""The node rhythm's labeller: the local hour of day at ``frac`` of the 24-slot sweep."""
return f"{round(frac * 23)} h"
def _quarter_axis(frac: float) -> str:
"""The mesh rhythm's labeller: the local hour at ``frac`` of a full-day quarter-hour sweep.
@@ -221,10 +226,16 @@ def _node_sections(
(o.observed_at, float(o.snr)) for o in observations if o.snr is not None
]
# Volume and SNR share one y-axis gutter width (like the mesh page's day pair) so
# their axes line up; a provisional width finds the peaks that size the gutter, then
# the real width re-buckets the bars flush with it. The rhythm below is its own
# narrow chart, so it keeps its own gutter.
# Volume, SNR, and the rhythm share one y-axis gutter width (like the mesh page's
# charts) so their left edges line up. A provisional width finds the peaks that size
# the gutter, then the real width re-buckets Volume/SNR flush with it. The rhythm folds
# every reception into 96 fifteen-minute local-time slices — a slice's tally can top a
# single volume bucket's — so its peak joins the sizing too.
slots = [0] * 96
for stamp in stamps:
local = stamp.astimezone()
slots[local.hour * 4 + local.minute // 15] += 1
def _layout(label_w: int) -> tuple[int, int]:
chars = max(20, width - 2 * (label_w + 2))
return chars, chars * 2
@@ -232,7 +243,9 @@ def _node_sections(
chars, buckets = _layout(1)
volume = bucketize(stamps, start, now, buckets)
lo, hi = chart_span(bucket_medians(snr_pairs, start, now, buckets)) if snr_pairs else (0.0, 0.0)
label_w = max(1, len(str(max(volume))), len(str(round(hi))), len(str(round(lo))))
label_w = max(
1, len(str(max(volume))), len(str(round(hi))), len(str(round(lo))), len(str(max(slots)))
)
chars, buckets = _layout(label_w)
volume = bucketize(stamps, start, now, buckets)
@@ -257,12 +270,14 @@ def _node_sections(
)
)
hours = [0] * 24
for stamp in stamps:
hours[stamp.astimezone().hour] += 1
out.append(Text())
out.append(_heading("Rhythm", "receptions by local hour of day"))
out.extend(axis_chart(timeline_rows(hours, rows=_CHART_ROWS), max(hours), 12, _hour_axis))
out.append(_heading("Rhythm", "receptions by local time of day · 15-min slices"))
out.extend(
axis_chart(
timeline_rows(slots, rows=_CHART_ROWS), max(slots), 48,
_quarter_axis, label_w=label_w,
)
)
out.append(Text())
out.append(_heading("Record", "this window"))
+21 -10
View File
@@ -272,6 +272,26 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
return "\n".join(lines)
def _dialog_layout(screen: Screen, cols: int, rows: int) -> tuple[int, int, int, list[str]]:
"""Size a floating dialog: ``(outer_width, vpad, viewport, body_lines)``."""
# Most dialogs stretch to a generous cap; a screen may instead request a natural width
# (a short confirm sized to its content), still bounded to the terminal.
cap = min(cols - 6, 100)
natural = getattr(screen, "dialog_width", None)
max_w = cap if natural is None else max(24, min(cap, natural))
# Rows the box may spend between its borders — on body lines and breathing room alike.
budget = max(3, rows - 6)
body_lines = screen.render_body(max_w - 4)
# A grow-only screen (the packet viewer paging between packets) sizes to the tallest
# body it has shown, not this one, so a shorter body keeps the larger box instead of
# re-centring smaller — the frame blank-pads the slack. The breathing room is taken
# from that ratcheted height, so the box stays put as the body shrinks below it.
body_h = screen.ratchet_viewport(max(1, len(body_lines)))
vpad = _breathing_room(body_h, budget)
viewport = min(budget - 2 * vpad, body_h)
return max_w, vpad, viewport, body_lines
def compose_dialog(screen: Screen, cols: int, rows: int) -> str:
"""Compose a centered dialog panel for a floating screen, bounded to the terminal.
@@ -283,16 +303,7 @@ def compose_dialog(screen: Screen, cols: int, rows: int) -> str:
Returns:
An ANSI string sized to the dialog's content (never larger than the terminal).
"""
# Most dialogs stretch to a generous cap; a screen may instead request a natural width
# (a short confirm sized to its content), still bounded to the terminal.
cap = min(cols - 6, 100)
natural = getattr(screen, "dialog_width", None)
max_w = cap if natural is None else max(24, min(cap, natural))
# Rows the box may spend between its borders — on body lines and breathing room alike.
budget = max(3, rows - 6)
body_lines = screen.render_body(max_w - 4)
vpad = _breathing_room(len(body_lines), budget)
viewport = min(budget - 2 * vpad, max(1, len(body_lines)))
max_w, vpad, viewport, body_lines = _dialog_layout(screen, cols, rows)
visible, more_above, more_below = _visible_slice(screen, body_lines, viewport)
body = Text.from_ansi("\n".join(visible))
border = getattr(screen, "border_style", "accent")
+24
View File
@@ -36,6 +36,11 @@ class Screen:
future: Resolved with the screen's result (or :data:`CANCEL`) when it commits.
floating: Whether the session should draw this screen as a centered dialog over
the dimmed screen beneath it (deeper layers float; the base does not).
grow_only: Whether a floating dialog may only ever grow. Its box is sized to the
tallest body it has shown, not the current one, so a screen whose body height
swings as its content changes (the packet viewer paging between packets) keeps
a steady, centred box — blank-padded when the current body is shorter — instead
of resizing on every page.
chrome: Whether, as the base screen, this layer is wrapped in the session's
persistent header/footer frame. A startup splash sets this ``False`` so the
session instead centers it under the :attr:`banner` with no status bars.
@@ -48,6 +53,7 @@ class Screen:
title: str = ""
footer_hint: str = "Esc back"
floating: bool = True
grow_only: bool = False
chrome: bool = True
banner: Optional[Sequence[str]] = None
footnote: Optional[str] = None
@@ -65,6 +71,11 @@ class Screen:
# clamp to the content without every caller threading the sizes through.
self._scroll_total = 1
self._scroll_viewport = 1
# A grow-only screen's high-water body height: the tallest body it has rendered
# into a dialog, so its box can be held at that size once reached (see
# :meth:`ratchet_viewport`). Zero until the first paint; irrelevant while not
# :attr:`grow_only`.
self._viewport_floor = 0
# --- rendering -----------------------------------------------------------
@@ -79,6 +90,19 @@ class Screen:
"""
raise NotImplementedError
def ratchet_viewport(self, body_h: int) -> int:
"""The body height a :attr:`grow_only` dialog is sized to: its running maximum.
The frame calls this each paint with the current body's height. A grow-only
screen returns the tallest height it has yet shown — so its box never shrinks,
and the frame blank-pads a now-shorter body to fill it — while an ordinary
screen returns the height unchanged and is sized to each body as it comes.
"""
if not self.grow_only:
return body_h
self._viewport_floor = max(self._viewport_floor, body_h)
return self._viewport_floor
def cursor_line(self) -> Optional[int]:
"""Return a body line that must stay visible, or ``None`` for free scrolling.
+1 -3
View File
@@ -677,9 +677,7 @@ class TuiSession:
content=base_window,
floats=[
Float(
ConditionalContainer(
float_window, filter=Condition(self._has_float)
)
ConditionalContainer(float_window, filter=Condition(self._has_float))
),
Float(
ConditionalContainer(
+3
View File
@@ -152,6 +152,9 @@ def _menu_items(
_TOGGLE_NEW,
)
)
items.append(Separator(""))
items.append(Choice("← Back", None)) # a visible exit beside Esc
return items
+21 -7
View File
@@ -98,13 +98,17 @@ def test_packet_viewer_without_a_source_stays_a_snapshot() -> None:
assert viewer.footer_hint == "Esc close"
def test_packet_viewer_holds_a_minimum_height() -> None:
"""A short packet's body is padded to a floor, so paging never shrinks the dialog."""
from meshterm.ui.packet_viewer import _MIN_BODY_ROWS
tiny = PacketEntry(when=utcnow(), kind="ack", where="01c3") # a two-row packet
def test_packet_viewer_grows_its_dialog_only() -> None:
"""The viewer opts into a grow-only dialog so paging enlarges but never shrinks it."""
tiny = PacketEntry(when=utcnow(), kind="ack", where="01c3")
viewer = PacketViewer([tiny], 0, resolve=lambda h: "")
assert len(viewer.render_body(80)) >= _MIN_BODY_ROWS
assert viewer.grow_only is True
# The body is left at its natural (unpadded) height; the frame, not a floor, pads it.
assert len(viewer.render_body(80)) < 10
# ratchet_viewport is the grow-only contract: it rises to a taller body and never drops.
assert viewer.ratchet_viewport(6) == 6
assert viewer.ratchet_viewport(14) == 14 # a taller packet enlarges the box
assert viewer.ratchet_viewport(4) == 14 # a shorter one after keeps the larger box
def test_packet_viewer_raw_dump_skips_fields_folded_into_flavoured_rows() -> None:
@@ -116,4 +120,14 @@ def test_packet_viewer_raw_dump_skips_fields_folded_into_flavoured_rows() -> Non
body = _plain(_viewer(entry).render_body(80))
assert "path_len" not in body
assert "header" not in body
assert "novel_fi" in body # an unrecognized field still surfaces (label truncates to 8)
assert "novel_field" in body # an unrecognized field surfaces with its full name
def test_packet_viewer_shows_full_raw_field_labels() -> None:
"""A long raw-field name is shown whole — the label lane widens rather than clipping it."""
entry = _grp_txt_entry({
"chan_hash": "ab", "cipher_mac": "0000", "crypted": "00" * 16,
"battery_millivolts": 4102,
})
body = _plain(_viewer(entry).render_body(80))
assert "battery_millivolts" in body # the 18-char key is not clipped to 8
+32 -1
View File
@@ -295,12 +295,43 @@ def test_node_page_renders_all_sections(tmp_path: Path) -> None:
body = _plain(_node_sections(ctx, NODE, "YUL", None, 90))
assert "Volume" in body and "24 receptions" in body
assert "SNR" in body and "-2.0" in body and "+8.0" in body
assert "Rhythm" in body and "local hour" in body
assert "Rhythm" in body and "15-min" in body # 15-minute slices, like the mesh page
assert "Record" in body and "median" in body
assert "advert 24" in body # kinds breakdown, packet rows absent
repo.close()
def test_node_page_rhythm_is_fifteen_minute_and_shares_the_volume_gutter(tmp_path: Path) -> None:
"""The node rhythm matches the mesh's: a full-day 15-min sweep, gutter-aligned to Volume."""
repo = _seeded_repo(tmp_path)
ctx = SimpleNamespace(repo=repo)
lines = _plain(_node_sections(ctx, NODE, "YUL", None, 90), width=90).split("\n")
def border_col(heading: str) -> int:
start = next(i for i, line in enumerate(lines) if heading in line)
border = next(line for line in lines[start:] if "└" in line)
return border.index("└")
assert border_col("Volume") == border_col("Rhythm") # shared gutter → aligned left edge
rhythm = next(i for i, line in enumerate(lines) if "Rhythm" in line)
caption = next(lines[i + 1] for i in range(rhythm, len(lines)) if "└" in lines[i])
assert "24 h" in caption # the full-day sweep closes on 24 h, like the mesh rhythm
def test_time_axis_shortens_to_dates_on_wide_windows_and_times_on_narrow() -> None:
"""A multi-day window labels marks as bare dates; a same-day one, as times."""
from datetime import datetime, timedelta, timezone
from meshterm.ui.timemachine_screen import _time_axis
start = datetime(2026, 7, 1, 8, 0, tzinfo=timezone.utc)
wide = _time_axis(start, start + timedelta(days=10))
narrow = _time_axis(start, start + timedelta(hours=12))
assert wide(1.0) == "now" and narrow(1.0) == "now"
assert any(ch.isalpha() for ch in wide(0.0)) # a month name, e.g. "Jul 1"
assert ":" in narrow(0.0) # a clock time, e.g. "08:00"
def test_node_page_empty_window_offers_widening(tmp_path: Path) -> None:
"""A window with nothing recorded says so instead of drawing empty charts."""
repo = _seeded_repo(tmp_path)
+39
View File
@@ -417,6 +417,45 @@ def test_compose_dialog_is_bounded() -> None:
assert out.count("\n") + 1 <= 20
def _box_height(screen: Screen) -> int:
"""The row height of the dialog box ``compose_dialog`` draws for ``screen``."""
return frame.compose_dialog(screen, 80, 40).count("\n") + 1
def test_ordinary_dialog_box_resizes_to_each_body() -> None:
"""A plain (non grow-only) dialog sizes to whatever body it is currently showing."""
short = _box_height(ScrollScreen(Text("one line"), title="d"))
tall = _box_height(ScrollScreen(Text("\n".join(f"row {i}" for i in range(15))), title="d"))
assert tall > short
class _GrowScreen(Screen):
"""A grow-only dialog whose body height is set per paint, for the ratchet test."""
grow_only = True
def __init__(self) -> None:
super().__init__()
self.title = "d"
self.footer_hint = ""
self.n = 1
def render_body(self, width: int) -> list[str]:
return [f"row {i}" for i in range(self.n)]
def test_grow_only_dialog_box_holds_its_tallest_size() -> None:
"""A grow-only dialog grows its box for a taller body and never shrinks for a shorter one."""
screen = _GrowScreen()
screen.n = 2
small = _box_height(screen)
screen.n = 16
grown = _box_height(screen)
assert grown > small # a taller body enlarges the box
screen.n = 2
assert _box_height(screen) == grown # a shorter body after keeps the larger box
def test_compose_startup_is_chromeless_and_shows_banner() -> None:
"""The startup splash fills the height, draws the banner, and omits header/footer bars."""
screen = SelectScreen("pick", [Choice("alpha", 1), Choice("beta", 2)])