diff --git a/meshterm/ui/courier_screen.py b/meshterm/ui/courier_screen.py index ececbfa..923ce15 100644 --- a/meshterm/ui/courier_screen.py +++ b/meshterm/ui/courier_screen.py @@ -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 diff --git a/meshterm/ui/packet_viewer.py b/meshterm/ui/packet_viewer.py index 369974b..9d725c3 100644 --- a/meshterm/ui/packet_viewer.py +++ b/meshterm/ui/packet_viewer.py @@ -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 diff --git a/meshterm/ui/timemachine_screen.py b/meshterm/ui/timemachine_screen.py index 8345a6b..19c0b58 100644 --- a/meshterm/ui/timemachine_screen.py +++ b/meshterm/ui/timemachine_screen.py @@ -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")) diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index 6e4b056..e483ff0 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -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") diff --git a/meshterm/ui/tui/screen.py b/meshterm/ui/tui/screen.py index 802260b..fc5a0d4 100644 --- a/meshterm/ui/tui/screen.py +++ b/meshterm/ui/tui/screen.py @@ -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. diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 126e62d..f2e5088 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -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( diff --git a/meshterm/ui/watchtower_screen.py b/meshterm/ui/watchtower_screen.py index e9a1a77..f939b48 100644 --- a/meshterm/ui/watchtower_screen.py +++ b/meshterm/ui/watchtower_screen.py @@ -152,6 +152,9 @@ def _menu_items( _TOGGLE_NEW, ) ) + + items.append(Separator("")) + items.append(Choice("← Back", None)) # a visible exit beside Esc return items diff --git a/tests/test_packet_viewer.py b/tests/test_packet_viewer.py index d72c919..26d43f8 100644 --- a/tests/test_packet_viewer.py +++ b/tests/test_packet_viewer.py @@ -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 diff --git a/tests/test_timemachine.py b/tests/test_timemachine.py index ccefee4..53473eb 100644 --- a/tests/test_timemachine.py +++ b/tests/test_timemachine.py @@ -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) diff --git a/tests/test_tui.py b/tests/test_tui.py index 6314a7d..347e168 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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)])