diff --git a/meshterm/ui/atlas_screen.py b/meshterm/ui/atlas_screen.py index 5d6a67b..46334e6 100644 --- a/meshterm/ui/atlas_screen.py +++ b/meshterm/ui/atlas_screen.py @@ -34,11 +34,13 @@ 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 › …``, 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 +across the top grows (a :mod:`~meshterm.ui.pathline` path line — powerline chips where the +terminal draws them, ``you › YUL-Cartierville › …`` where it doesn't, 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 it neither wraps nor +scrolls: its *head* goes behind a leading ``⋯`` and the rest snaps flush right, so the +focus and the steps just taken stay in view. **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). Esc peels find first, the @@ -64,6 +66,7 @@ from ..services.topology import Link, MeshTopology from .map_render import _NODE, _REPEATER, _SELF, _UNKNOWN from .mapcanvas import RGB, MapCanvas, parse_hex from .menus import fit_cells +from .pathline import ELIDE_HEAD, PathHop, PathLine from .theme import name_style, node_style, snr_style from .trace_screen import snr_bar from .tui.render import render_to_ansi @@ -466,42 +469,41 @@ class AtlasScreen(Screen): ] def _trail_text(self, width: int) -> Text: - """The breadcrumb trail, each name in its own key-derived hue, tail-anchored. + """The breadcrumb trail as a path line: one line, tail-anchored, never wrapped. - 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. + The walk *is* a path — us, then every node stepped through, ending on the focus — + so it renders through :class:`~meshterm.ui.pathline.PathLine` like every other hop + sequence in the app: powerline chips wherever the terminal can draw them, the + trail's own ``›`` arrows where it can't. Each hop wears its node's key-derived hue + (ours the white ``you``; a node known only by a bare hash stays muted — colour is + reserved for keyed identities), so the trail and the rows below it agree on who is + who. + + The line neither wraps nor scrolls. When the walk outgrows the width the fit eats + into its *head* (:data:`~meshterm.ui.pathline.ELIDE_HEAD`) rather than a route's + usual tail: the oldest steps disappear behind a leading ``⋯`` and what survives + snaps flush against the **right** edge, so the focus and the steps that just led + to it are the ones always in view. A long walk therefore reads as a line that + grows rightward until it meets the margin and then starts shedding its oldest + steps, rather than one that pushes the focus off the end. """ - nodes = self._trail - sep = " › " + line = PathLine([self._trail_hop(node) for node in self._trail], separator=" › ") + full = line.text() + if full.cell_len <= width: + return full + fitted = line.ellipsized(width, elide=ELIDE_HEAD) + snapped = Text(" " * max(0, width - fitted.cell_len)) # snap the tail to the edge + snapped.append_text(fitted) + return snapped - 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 _trail_hop(self, node: str) -> PathHop: + """One walked step as a path hop: its display name in its own identity colour.""" + style = self._list_name_style(node) + return PathHop( + self._label(node), + key=None if style in ("you", "muted") else node, + you=style == "you", + ) def _focus_line(self, depths: dict[str, int]) -> Text: """Who is in focus: glyph, name with its parenthesized hash, distance, and recency. diff --git a/meshterm/ui/pathline.py b/meshterm/ui/pathline.py index 2295064..a98bb38 100644 --- a/meshterm/ui/pathline.py +++ b/meshterm/ui/pathline.py @@ -21,8 +21,10 @@ drawn*, so every surface the survey found can eventually route through it: * **Three overflow answers**, matching the three patterns the surfaces already use: :meth:`PathLine.text` is the full one-liner (for callers that ellipsize or h-scroll it themselves), :meth:`PathLine.ellipsized` fits a width by eliding - *middle* hops behind a ``⋯`` mark — both endpoints survive, unlike a tail - truncation that amputates the destination — and :meth:`PathLine.wrapped` breaks + hops behind a ``⋯`` mark, on whichever side it is told to eat into + (:data:`ELIDE_TAIL` by default — both endpoints survive, unlike a plain tail + truncation that amputates the destination; :data:`ELIDE_HEAD` for a breadcrumb + trail, which anchors on where the walk *is*) — and :meth:`PathLine.wrapped` breaks at hop boundaries under a hanging indent (never mid-name, never mid-chip), folding where the route *means* something: the fewest lines it can take, evened out across them rather than greedily crammed, and preferring the seam where the @@ -104,10 +106,17 @@ _YOU_BG = "#ffffff" _DIM_BG = "#334155" _DIM_FG = "#94a3b8" -#: The mark standing in for elided middle hops (see :meth:`PathLine.ellipsized`) — -#: rendered as a dim pseudo-hop so it recedes in both modes. +#: The mark standing in for elided hops (see :meth:`PathLine.ellipsized`) — rendered as +#: a dim pseudo-hop so it recedes in both modes. _ELISION = "⋯" +#: Which side of a path an :meth:`PathLine.ellipsized` fit eats into. ``ELIDE_TAIL`` is +#: the route reading — the origin anchors the line, the destination is rescued off the +#: far end, and the mark lands in the middle. ``ELIDE_HEAD`` is the trail reading — the +#: last hop anchors the line and everything before it goes, origin included. +ELIDE_TAIL = "tail" +ELIDE_HEAD = "head" + #: Extra columns a wrapped line steps in past the hanging indent. The continuation #: marks (a trailing ``→``, a notched chip edge) say the path goes on; the step says #: it at a glance, from the shape of the block alone — a route that folds reads as one @@ -214,16 +223,28 @@ class PathLine: return Text(self._empty, style="muted") return self._render(self._hops) - def ellipsized(self, width: int) -> Text: - """The line fitted to ``width`` by eliding *middle* hops behind ``⋯``. + def ellipsized(self, width: int, *, elide: str = ELIDE_TAIL) -> Text: + """The line fitted to ``width`` by eliding hops behind a ``⋯`` mark. - Endpoints matter most — a route reads origin and destination first — so the - fit keeps the first hop and as much of the tail as possible, then drops the - head too if it must, and only as a last resort truncates a single over-long - hop the classic way. + ``elide`` names the *side* the mark eats into — which end of the path pays for + the overflow: + + * :data:`ELIDE_TAIL` (the default) anchors the line on its head: the origin + holds and hops go from the tail end. The destination is rescued out of that + side along with as much of the run before it as fits, so the ``⋯`` settles in + the *middle* and both endpoints survive — a route reads origin and + destination first, and a plain right-truncation would amputate the second. + The origin is only given up if even that won't fit, and a lone over-long hop + is truncated the classic way as the last resort. + * :data:`ELIDE_HEAD` anchors the line on its tail: the last hop holds and hops + go from the head end, the origin among them, with no endpoint rescued. A + breadcrumb trail's news is where the walk *is*; where it set out from is + exactly the part worth losing. Args: width: The cell budget the returned line must fit in. + elide: Which side the ``⋯`` eats into — :data:`ELIDE_TAIL` (the default, + keeping both endpoints) or :data:`ELIDE_HEAD` (keeping the tail alone). Returns: A one-line :class:`Text` no wider than ``width``. @@ -233,7 +254,10 @@ class PathLine: return full mark = PathHop(_ELISION, dim=True) count = len(self._hops) - for head in (1, 0): + # The heads worth trying, most-rescued first: a tail-side elision spares the + # origin while it fits and gives it up only when it must; a head-side one is + # eating the head on purpose, so it never spares it at all. + for head in ((0,) if elide == ELIDE_HEAD else (1, 0)): for tail in range(count - 1 - head, 0, -1): kept = self._hops[:head] + [mark] + self._hops[-tail:] candidate = self._render(kept) diff --git a/tests/test_atlas.py b/tests/test_atlas.py index e5b9bf1..c24ae77 100644 --- a/tests/test_atlas.py +++ b/tests/test_atlas.py @@ -269,16 +269,25 @@ def test_atlas_walking_to_an_earlier_node_drops_the_loop() -> None: 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.""" + """A trail too long for the line loses its head behind a leading ⋯, snapped right.""" 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("…") + width = 20 # too narrow for the whole "Homestead › YUL-Cartierville › Alice" + text = screen._trail_text(width).plain + assert text.lstrip().startswith("⋯") assert text.endswith("Alice") # the focus is always kept assert "Homestead" not in text # the head was dropped, not the tail + assert len(text) == width # the surviving tail snaps flush to the right edge + + +def test_atlas_trail_sits_left_until_it_overflows() -> None: + """A trail that fits is left-aligned; only an elided one snaps to the right edge.""" + screen = _screen(_topo()) + screen._trail = [screen._topo.self_id] + assert screen._trail_text(40).plain == "Homestead" def test_atlas_trail_names_carry_their_node_hues() -> None: diff --git a/tests/test_pathline.py b/tests/test_pathline.py index 35af46d..2e230bd 100644 --- a/tests/test_pathline.py +++ b/tests/test_pathline.py @@ -14,7 +14,7 @@ import pytest import meshterm.ui.pathline as pathline from meshterm.ui.pathline import ( - CURSOR_GLYPH, POWERLINE_ROUND_CLOSE, POWERLINE_ROUND_OPEN, POWERLINE_SEP, + CURSOR_GLYPH, ELIDE_HEAD, POWERLINE_ROUND_CLOSE, POWERLINE_ROUND_OPEN, POWERLINE_SEP, SELF_GLYPH, WRAP_OFFSET, PathHop, PathLine, _style_hex, path_line, ) from meshterm.ui.theme import node_style @@ -153,6 +153,20 @@ def test_ellipsized_elides_the_middle_and_keeps_both_endpoints() -> None: assert "⋯" in fitted.plain +def test_ellipsized_eats_the_head_when_told_to() -> None: + """A breadcrumb's news is where the walk *is*, so ``ELIDE_HEAD`` spares the origin + nothing: the fit keeps as much of the tail as the width holds, behind a ``⋯``.""" + hops = [PathHop(label) for label in ("AAAA", "BBBB", "CCCC", "DDDD", "EEEE", "FFFF")] + line = PathLine(hops, mode="plain") + fits = line.ellipsized(200, elide=ELIDE_HEAD).plain + assert fits == line.text().plain # fits → untouched, whichever side would give + fitted = line.ellipsized(25, elide=ELIDE_HEAD) + assert fitted.cell_len <= 25 + assert fitted.plain.startswith("⋯") # the origin goes too, unlike the tail-side fit + assert fitted.plain.endswith("FFFF") + assert "AAAA" not in fitted.plain + + def test_ellipsized_last_resort_truncates_a_single_giant_hop() -> None: """When even ``⋯ → last`` overflows, the classic ellipsis truncation steps in.""" line = PathLine([PathHop("AAAAAAAAAA"), PathHop("BBBBBBBBBB")], mode="plain")