diff --git a/meshterm/ui/atlas_screen.py b/meshterm/ui/atlas_screen.py index 71e2119..129ec43 100644 --- a/meshterm/ui/atlas_screen.py +++ b/meshterm/ui/atlas_screen.py @@ -28,8 +28,12 @@ only its immediate neighbourhood: PgUp/PgDn a windowful. **Enter walks**: the highlighted neighbour becomes the new focus, the breadcrumb trail -across the top grows (``you › YUL-Cartierville › …``), and **⌫ steps back** along it. -**Home** refocuses our own node. **Typing finds** — a global filter over every node in +across the top grows (``you › YUL-Cartierville › …``, each name in its node's own hue), +and **⌫ steps back** along it. Walking to a node already on the trail truncates the stack +to its first appearance — the loop you walked to get back there is dropped rather than +recorded — and when the trail outgrows the line its *head* is dropped behind a leading +``…`` so the focus stays visible. **Home** refocuses our own node. **Typing finds** — a +global filter over every node in the graph, islands included; Enter teleports the focus to the highlighted match (the trail restarts there, since the walk didn't cross the gap). ``^R`` rebuilds the graph from storage, and Esc peels find first, the screen second. @@ -42,6 +46,7 @@ from collections import deque from datetime import datetime from typing import TYPE_CHECKING, Callable, Optional +from rich.cells import cell_len from rich.text import Text from ..core.models import ( @@ -337,8 +342,12 @@ class AtlasScreen(Screen): # gap, so pretending it did would make ⌫ retrace a path never taken. self._trail = [target] self._filter = "" - elif target == self._came_from: - self._trail.pop() # walking back through the west node = one step back + elif target in self._trail: + # Revisiting a node already on the trail — stepping back through the west + # node, or looping round to an earlier one — truncates the stack to that + # node's first appearance. We drop the circular stretch we walked to get + # back here rather than recording the round trip; losing the loop is the point. + self._trail = self._trail[: self._trail.index(target) + 1] else: self._trail.append(target) self._index = 0 @@ -425,19 +434,48 @@ class AtlasScreen(Screen): """The breadcrumb trail (when walking) and the focus node's identity line.""" out: list[str] = [] if len(self._trail) > 1: - trail = Text() - for i, node in enumerate(self._trail): - if i: - trail.append(" › ", style="muted") - last = i == len(self._trail) - 1 - trail.append( - self._label(node), style="bold" if last else "muted" - ) - trail.truncate(width, overflow="ellipsis") - out.append(render_to_ansi(trail, width, no_wrap=True)) + out.append(render_to_ansi(self._trail_text(width), width, no_wrap=True)) out.append(render_to_ansi(self._focus_line(depths), width, no_wrap=True)) return out + def _trail_text(self, width: int) -> Text: + """The breadcrumb trail, each name in its own key-derived hue, tail-anchored. + + Names carry the app-wide per-node hue (ours the white ``you``, a nameless node + muted), the focus bold. When the whole trail won't fit on the line the *head* is + dropped behind a leading ``…`` — never the tail — so the focus and the steps that + led to it are always the ones kept in view. + """ + nodes = self._trail + sep = " › " + + def width_of(start: int) -> int: + total = cell_len("…") + cell_len(sep) if start else 0 # the leading "… › " + for k in range(start, len(nodes)): + total += cell_len(sep) if k > start else 0 + total += cell_len(self._label(nodes[k])) + return total + + start = 0 + while start < len(nodes) - 1 and width_of(start) > width: + start += 1 + + trail = Text() + if start: + trail.append("…" + sep, style="muted") + for k in range(start, len(nodes)): + if k > start: + trail.append(sep, style="muted") + last = k == len(nodes) - 1 + trail.append(self._label(nodes[k]), style=self._trail_style(nodes[k], last)) + trail.truncate(width, overflow="ellipsis") # guard a lone label wider than the line + return trail + + def _trail_style(self, node: str, last: bool) -> str: + """A trail name's style: its key hue (ours white, a nameless node muted), focus bold.""" + base = self._list_name_style(node) + return f"bold {base}" if last else base + def _focus_line(self, depths: dict[str, int]) -> Text: """Who is in focus: glyph, name, hash, kind, distance, and recency.""" node = self._focus diff --git a/tests/test_atlas.py b/tests/test_atlas.py index 32ae503..c91ea15 100644 --- a/tests/test_atlas.py +++ b/tests/test_atlas.py @@ -134,6 +134,60 @@ def test_atlas_walking_into_the_back_node_pops_instead_of_growing() -> None: assert screen._trail == [topo.self_id] # popped, not [us, yul, us] +def test_atlas_walking_to_an_earlier_node_drops_the_loop() -> None: + """Revisiting a node already on the trail truncates the stack to its first appearance, + dropping the circular stretch walked to get back there.""" + topo = MeshTopology(US, contacts=[YUL, ALICE]) + yul = topo.canonical(YUL.public_key) + alice = topo.canonical(ALICE.public_key) + when = utcnow() + topo.add_walk([topo.self_id, yul], snrs=[6.0], when=when, source="trace") + topo.add_walk([yul, alice], snrs=[-2.0], when=when, source="packet") + topo.add_walk([topo.self_id, alice], snrs=[3.0], when=when, source="trace") # closes the loop + screen = _screen(topo) + + def walk_to(node: str) -> None: + screen.render_body(80) + screen._index = screen._rows().index(node) + screen.handle("enter") + + walk_to(yul) # us → YUL + walk_to(alice) # YUL → Alice + assert screen._trail == [topo.self_id, yul, alice] + walk_to(yul) # Alice → YUL: loops back, so Alice is dropped, not re-appended + assert screen._trail == [topo.self_id, yul] + + +def test_atlas_trail_drops_the_head_not_the_tail_when_narrow() -> None: + """A trail too long for the line loses its head behind a leading …, keeping the focus.""" + screen = _screen(_topo()) + us = screen._topo.self_id + yul = screen._topo.canonical(YUL.public_key) + alice = screen._topo.canonical(ALICE.public_key) + screen._trail = [us, yul, alice] + text = screen._trail_text(20).plain # too narrow for the whole "Homestead › … › Alice" + assert text.startswith("…") + assert text.endswith("Alice") # the focus is always kept + assert "Homestead" not in text # the head was dropped, not the tail + + +def test_atlas_trail_names_carry_their_node_hues() -> None: + """Trail names take the per-node key hue (ours the white you-style), the focus bold.""" + from meshterm.ui.theme import node_style + + screen = _screen(_topo()) + us = screen._topo.self_id + yul = screen._topo.canonical(YUL.public_key) + screen._trail = [us, yul] + text = screen._trail_text(80) + plain = text.plain + yul_at = plain.index("YUL-Cartierville") + assert any( + s.start <= yul_at < s.end and "bold" in str(s.style) and node_style(yul) in str(s.style) + for s in text.spans + ) + + def test_atlas_home_refocuses_us() -> None: """Home resets the walk to our own node from anywhere.""" topo = _topo()