diff --git a/meshterm/ui/node_detail_screen.py b/meshterm/ui/node_detail_screen.py index c430ebe..3516bf8 100644 --- a/meshterm/ui/node_detail_screen.py +++ b/meshterm/ui/node_detail_screen.py @@ -14,7 +14,10 @@ the node itself, in full: * **Info** — the node's vitals as labelled rows (its key with the routing hash lit, when it was first and last heard, how many packets we've overheard, its reception SNR and - last RSSI, and where it sits), then — when the node has advertised a location — a + last RSSI, and where it sits). The key is a single lane, never wrapped: a full public + key outruns the row on any terminal we target, so ``←→`` scroll it a whole four bytes + at a time under faint ``…`` edge marks, exactly as a long pathline scrolls on the + Routes tab. Then — when the node has advertised a location — a static basemap preview (see :class:`~meshterm.ui.minimap.MiniMap`) centred on the node, grown to whatever rows the viewport spares. Its actions: ``Open full map`` (the full map opens centred here with its find filter seeded to this node, so it lights among @@ -169,11 +172,21 @@ _ROUTE_INDENT = 2 #: measuring/cropping the raw hash chain for horizontal scroll never has to account for it. _OPENS_MARKER = " …" -#: Cells one ←/→ press horizontally scrolls the highlighted route's pathline (see -#: :meth:`NodeDetailScreen.handle`) — the same step the app-wide select list's ``hscroll`` -#: uses. +#: Cells one ←/→ press horizontally scrolls the highlighted route's pathline, or the Info +#: tab's key lane (see :meth:`NodeDetailScreen.handle`) — the same step the app-wide select +#: list's ``hscroll`` uses. On a key it happens to be exactly four bytes, so every window the +#: lane can settle on starts on a byte boundary, the way a hash reads. _HSCROLL_STEP = 8 +#: The info row whose value is a key: the one lane that scrolls (``←→``) rather than wraps. +#: A public key is 64 hex digits — wider than the value lane on either platform, and a run +#: with no word to break on — so wrapping it just buys a second line that says nothing, +#: while every other vital here is prose short enough to hang under itself. +_KEY_LABEL = "key" + +#: The faint mark drawn at whichever edge of the key lane the key continues past. +_MORE_MARK = "…" + #: Cursor moves that abandon a route's in-progress horizontal scroll — each row scrolls on #: its own, so leaving it resets the shift rather than carrying it to whatever row is next. _HSHIFT_RESET_ACTIONS = frozenset({ @@ -302,6 +315,10 @@ class NodeDetailScreen(Screen): itself never scrolls: the identity header, tab strip, and stage are pinned, the stage is sized to the viewport, and the route list windows itself into the leftover rows — ``PgUp/PgDn`` page the cursor through it, ``Home/End`` jump it to the ends. + + ``←→`` read whatever the active tab holds that is wider than its lane, one lane per tab: + the highlighted route's pathline on Routes, the key on Info. Both are gated on actually + overflowing, so the keys stay inert — and unadvertised — where they would do nothing. """ floating = False @@ -371,6 +388,11 @@ class NodeDetailScreen(Screen): #: The highlighted route's pathline horizontal scroll (cells shifted in, ``←/→``); #: resets whenever the cursor leaves that row (see :data:`_HSHIFT_RESET_ACTIONS`). self._hshift = 0 + #: The Info tab's key lane horizontal scroll (cells shifted in, ``←/→``). Unlike the + #: route pathline's, it is not the cursor's — the key is pinned chrome in the stage, + #: no row you can land on — so moving through the action rows leaves it where the + #: reader put it; only leaving the tab resets it. + self._key_shift = 0 #: The last render width, so the footer hint can tell whether the highlighted #: route's pathline actually overflows (nothing does before the first paint). self._last_width = 0 @@ -379,11 +401,15 @@ class NodeDetailScreen(Screen): @property def footer_hint(self) -> str: # type: ignore[override] - """Tab switch (when there are tabs to switch), move, open, list/pathline scroll, Esc last. + """Tab switch (when there are tabs to switch), move, open, whatever ``←→`` reads here, + Esc last. The scroll atom folds ``PgUp/PgDn`` and ``←→`` into one when both apply (a busy node's list is windowed *and* its highlighted route overflows) rather than stacking two atoms - and risking the 72-column hint budget. + and risking the 72-column hint budget. On the Info tab the ``←→`` atom names its + subject — the key is the one thing there that scrolls, and the cursor is elsewhere. + Each atom is gated on there actually being something to scroll, so the line never + advertises a key that would do nothing. """ parts: list[str] = [] if len(self._tabs) >= 2: @@ -396,6 +422,8 @@ class NodeDetailScreen(Screen): parts.append("PgUp/PgDn scroll") elif hscroll: parts.append("←→ scroll") + elif self._key_overflows(): + parts.append("←→ scroll key") parts.append("Esc back") return " · ".join(parts) @@ -418,6 +446,37 @@ class NodeDetailScreen(Screen): total = cell_len(routes[self._row_index].path.plain) + cell_len(_OPENS_MARKER) return total > avail + def _on_info_tab(self) -> bool: + """Whether the Info tab is the one filling the stage (so its key lane is on screen).""" + tab = self._tabs[self._tab_index] if self._tabs else None + return tab is not None and tab.kind == "info" + + def _key_value(self) -> Optional[Text]: + """The Info tab's key value — the one vital rendered as a scrolling lane, or ``None``. + + Found by its label rather than its position: the block is assembled elsewhere + (:func:`open_node_detail`) and the rows around it come and go with what is known + about the node, but the row *labelled* :data:`_KEY_LABEL` is always the key. + """ + for label, value in self._info_rows: + if label == _KEY_LABEL: + return value + return None + + def _key_overflows(self) -> bool: + """Whether the key is wider than its lane — gating both ``←→`` and the footer atom. + + Measured against the last render width (``0`` before the first paint, so nothing + reads as overflowing until a real width is known), exactly as the route pathline's + sibling probe is. + """ + if self._last_width <= 0 or not self._on_info_tab(): + return False + value = self._key_value() + if value is None: + return False + return cell_len(value.plain) > max(1, self._last_width - _LABEL_LANE) + def selected_spec(self) -> str: """The forced-path spec of the currently-highlighted route (``""`` = auto). @@ -437,7 +496,9 @@ class NodeDetailScreen(Screen): return 2 if self._tabs[self._tab_index].kind == "info" else 0 def handle(self, action: str, data: str = "") -> None: - """Switch tab, move the cursor within a tab, commit a row, page the list, or leave.""" + """Switch tab, move the cursor within a tab, commit a row, page the list, scroll + whichever over-wide lane the active tab owns (the highlighted route's pathline, the + Info tab's key), or leave.""" focus = self._focusables() n = len(focus) if action in _HSHIFT_RESET_ACTIONS: @@ -486,9 +547,13 @@ class NodeDetailScreen(Screen): elif action == "left": if self._on_route_row(focus): self._hshift = max(0, self._hshift - _HSCROLL_STEP) + elif self._key_overflows(): + self._key_shift = max(0, self._key_shift - _HSCROLL_STEP) elif action == "right": if self._on_route_row(focus): self._hshift += _HSCROLL_STEP # clamped to the pathline's tail at render + elif self._key_overflows(): + self._key_shift += _HSCROLL_STEP # clamped to the key's tail at render elif action == "escape": self.resolve(CANCEL) @@ -510,6 +575,7 @@ class NodeDetailScreen(Screen): self._route_sel = 0 self._list_top = 0 self._hshift = 0 + self._key_shift = 0 self.scroll_to_top() self._sync_route_sel(self._focusables()) @@ -675,10 +741,16 @@ class NodeDetailScreen(Screen): ``budget`` is the viewport lines left for the whole stage; the vitals rows never truncate — it is the preview that flexes, taking whatever they and its caption - leave, clamped to ``[_MAP_MIN_ROWS, _MAP_MAX_ROWS]``. + leave, clamped to ``[_MAP_MIN_ROWS, _MAP_MAX_ROWS]``. Every row but the key hangs + under itself when it wraps; the key holds its one line and scrolls (see + :meth:`_key_line`), so a row count the preview sizes against can't move as a reader + walks a long key. """ lines: list[str] = [""] # the stage's one line of air under the strip for label, value in self._info_rows: + if label == _KEY_LABEL: + lines.append(self._key_line(label, value, width)) + continue lines.extend( render_hanging( Text(f"{label:<{_LABEL_LANE}}", style="muted"), @@ -696,6 +768,47 @@ class NodeDetailScreen(Screen): lines.extend(render_lines(self._map_caption, width, no_wrap=True)) return lines + def _key_line(self, label: str, value: Text, width: int) -> str: + """The key vital as one scrolling lane: the label, then the window ``←→`` settled on. + + A full public key is 64 hex digits and the lane is whatever the terminal leaves after + the label — 63 cells at the 72-column standard, fewer on the PicoCalc — so the key + never fits, and wrapping it would spend a whole second line on the handful of digits + that fell off. Instead the lane shows a window of it and :attr:`_key_shift` slides + that window (``←→``, :data:`_HSCROLL_STEP` cells a press), with a faint + :data:`_MORE_MARK` at whichever edge the key continues past — the affordance that says + the row is a viewport, not the whole value. + + The marks sit *beside* the window rather than over its first cell, so the digits keep + their byte alignment at every shift (the step is four whole bytes): a key always reads + in byte pairs, and a window starting mid-byte would spell it wrong. The shift is + clamped here, against the width actually being drawn, to the first step that brings + the key's tail into the lane — so scrolling can never run off into empty lane, and a + resize only ever pulls an out-of-range shift back in. + """ + lane = Text(no_wrap=True) + lane.append(f"{label:<{_LABEL_LANE}}", style="muted") + avail = max(1, width - _LABEL_LANE) + total = cell_len(value.plain) + if total <= avail: + self._key_shift = 0 + lane.append_text(value) + return render_to_ansi(lane, width, no_wrap=True) + # The lane gives up a cell to the left mark the moment it scrolls, so its last window + # spans ``avail - 1`` cells; the ceiling is the first whole step that reaches the end. + steps = -(-(total - (avail - 1)) // _HSCROLL_STEP) + self._key_shift = max(0, min(self._key_shift, steps * _HSCROLL_STEP)) + shift = self._key_shift + left = 1 if shift else 0 + inner = avail - left + right = 1 if shift + inner < total else 0 + if left: + lane.append(_MORE_MARK, style="muted") + lane.append_text(crop_cells(value, shift, inner - right)) + if right: + lane.append(_MORE_MARK, style="muted") + return render_to_ansi(lane, width, no_wrap=True) + def _routes_stage( self, width: int, budget: int, route_blocks: list[list[str]] ) -> list[str]: diff --git a/tests/test_node_detail.py b/tests/test_node_detail.py index 7776974..063b2d0 100644 --- a/tests/test_node_detail.py +++ b/tests/test_node_detail.py @@ -660,6 +660,67 @@ def test_node_detail_screen_hscrolls_the_selected_pathline() -> None: assert "←→ scroll" in screen.footer_hint # route 0 overflows again +#: A 64-digit key whose every byte is distinct (``000102…1e1f``), so a window's first digits +#: say exactly where the lane was cut — an off-by-one shift reads ``0001…`` as ``00102…``. +_LONG_KEY = "".join(f"{i:02x}" for i in range(32)) + + +def _key_row(screen: NodeDetailScreen, width: int) -> str: + """Render the Info tab and read back its key lane, as one plain line.""" + screen.note_viewport(30) + rows = [_plain([line]) for line in screen.render_body(width)] + return next(row for row in rows if row.startswith("key")) + + +def test_node_detail_key_lane_hscrolls_instead_of_wrapping() -> None: + """The key holds one line and slides under ←/→: byte-aligned windows, faint ``…`` marks + at whichever edge it continues past, clamped at both ends.""" + screen = _screen(info_rows=[("key", highlighted_hash(_LONG_KEY, 1)), ("heard", Text("5m ago"))]) + + row = _key_row(screen, 53) + assert row.startswith("key 0001") and row.endswith("…") # the head, more to the right + assert "1e1f" not in row and len(row) <= 53 # the tail is off-lane, not wrapped below it + assert "←→ scroll key" in screen.footer_hint # …and the overflow earns its footer atom + + screen.handle("right") + row = _key_row(screen, 53) + # Shifted a whole four bytes: the window opens on byte 4 (``04``), marked at both edges. + assert row.startswith("key …0405") and row.endswith("…") + + for _ in range(6): + screen.handle("right") # run at the end — the shift clamps where the tail lands + row = _key_row(screen, 53) + assert row.endswith("1e1f") # the key's last byte, no trailing mark: this is the end + assert row.startswith("key …") # …with the head now off to the left + + for _ in range(6): + screen.handle("left") # and back the other way, clamping at the head + assert _key_row(screen, 53).startswith("key 0001") + + +def test_node_detail_key_lane_scroll_survives_the_cursor_and_resets_on_tab() -> None: + """The key is pinned chrome, not a cursor row: walking the action rows leaves its scroll + alone, while leaving the tab drops it — and a key that fits earns no ←→ at all.""" + screen = _screen(info_rows=[("key", highlighted_hash(_LONG_KEY, 1)), ("heard", Text("5m ago"))]) + _key_row(screen, 53) + screen.handle("right") + screen.handle("down") # onto Back — the reader's window holds where they left it + assert _key_row(screen, 53).startswith("key …0405") + + screen.handle("tab") # over to Routes… + screen.render_body(53) + assert "←→" not in screen.footer_hint # nothing over-wide there to scroll + screen.handle("tab") # …and back: the lane opens at the head again + assert _key_row(screen, 53).startswith("key 0001") + + # A lane wide enough for the whole key draws it whole, with neither mark nor hint. + row = _key_row(screen, 100) + assert row.startswith(f"key {_LONG_KEY}") and "…" not in row + assert "←→" not in screen.footer_hint + screen.handle("right") # inert where there is nothing to read past the edge + assert _key_row(screen, 100).startswith(f"key {_LONG_KEY}") + + def test_node_detail_enter_on_a_route_row_opens_its_trace() -> None: """Enter on any route row arms a trace on that route — no separate Trace row needed.""" screen = _screen(routes=_two_routes(), tabs=[_Tab("Routes", "routes")])