diff --git a/CLAUDE.md b/CLAUDE.md index 73b62ed..e9d0b40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,10 +125,15 @@ Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now a `■` room · `◉` sensor · `○` unknown. - Path cuts: a path line drawn as **chips** that gets cut — a lane that ran out, a row scrolled past its edge — breaks the chip off on a half block in that chip's own fill - (`▌` end of line, `▐` start; `pathline.cut_mark`/`cut_to`), so half the cell is segment - and half is bare page. An ellipsis would say a *word* was shortened; the crack says the - segment continues. Arrow-drawn paths keep the `…` — nothing to shear. Distinct from - `⋯`, which marks whole hops *elided* out of the middle (`PathLine.ellipsized`). + (`▐` ends a line, `▌` opens one; `pathline.cut_mark`/`cut_to`), so half the cell is + segment and half is bare page. An ellipsis would say a *word* was shortened; the crack + says the segment continues. Arrow-drawn paths keep the `…` — nothing to shear. Distinct + from `⋯`, which marks whole hops *elided* out of the middle (`PathLine.ellipsized`). +- Path endpoints: a path line runs between the nodes it actually went between — the true + origin and destination, never the first and last *relay*. Our own end is always the + app-wide `★` (`pathline.SELF_GLYPH`, or `path_line(bare_self=True)`), never our name: + the one node the reader never has to be told, and the cells belong to the hops that + differ from row to row. - Concept icons: 📡 advert · 🕒 clock/sync · 🔄 reboot · 💾 backup · 📂 restore · 🔑 channel/credential key · 🔐 identity/auth secret · 🗑 clear/delete · ✎ compose/edit · ⚙ parameter · `#` count · ▶ run · ⚡ explore/probe · ★ best/winner · ⭐ watch · diff --git a/meshterm/ui/livefeed_screen.py b/meshterm/ui/livefeed_screen.py index 6715b19..318f8f3 100644 --- a/meshterm/ui/livefeed_screen.py +++ b/meshterm/ui/livefeed_screen.py @@ -56,7 +56,7 @@ from .packet_viewer import ( class_marks, node_label, ) -from .pathline import PathHop, PathLine +from .pathline import SELF_GLYPH, PathHop, PathLine from .theme import name_style, snr_style from .tui.render import crop_cells, render_to_ansi from .tui.screen import ListWindow, Screen @@ -667,6 +667,11 @@ class LiveFeedScreen(Screen): def _hop(self, value: str, *, named: bool = True) -> PathHop: """One endpoint as a path hop: its contact name, or its one-byte hash in that hue. + Our own node resolves to the app-wide :data:`~meshterm.ui.pathline.SELF_GLYPH` + rather than its name — the one endpoint the reader never has to be told, and in an + 18-cell lane the cells it gives back are the *other* end's name reading whole + instead of being cut. + Args: value: The endpoint's key hash (or, for an anonymous request's sender, its whole key — shown cut to the lane's one-byte width when it can't be named). @@ -675,8 +680,10 @@ class LiveFeedScreen(Screen): """ short = value[: 2 * ENDPOINT_HASH_BYTES] name = self._resolve(value) if named else None + if name and name == self._self_name: + return PathHop(SELF_GLYPH, you=True) if name and name != value: - return PathHop(name, key=value, you=bool(self._self_name and name == self._self_name)) + return PathHop(name, key=value) return PathHop(short, key=value, lit_bytes=ENDPOINT_HASH_BYTES) @staticmethod diff --git a/meshterm/ui/message_paths_screen.py b/meshterm/ui/message_paths_screen.py index 2d80ea7..594975d 100644 --- a/meshterm/ui/message_paths_screen.py +++ b/meshterm/ui/message_paths_screen.py @@ -15,17 +15,19 @@ detail read together: so the graph's labels stay two cells wide and a many-path graph stays readable. * the **arrival list** beneath is two lines per logged copy. On top, alone on its lane and introduced by nothing, the **route** as a path line (:mod:`~meshterm.ui.pathline`): - each relay a chip in its own node hue, named where we know the node (``YUL-Poly``) and - standing in its own hash at the device's path-hash width where we don't (``e839f2``, - keyless grey). No hash is repeated after a name — the graph above is where the hash + the origin, every relay, and us — the same two endpoints the graph draws between, so + the row and the picture start and finish in the same places. Each node is a chip in its + own hue, named where we know it (``YUL-Poly``) and standing in its own hash at the + device's path-hash width where we don't (``e839f2``, keyless grey); our own end is the + app-wide ``★``. No hash is repeated after a name — the graph above is where the hash bytes live, and a chip and its label share the node's colour, so the two halves cross-reference by hue instead of by spelling the hex twice. Under it, hanging muted, the frame's own facts: time heard, reception SNR, and which resend it was. The route is the line you pick — it is what one arrival differs from another by, and what the graph highlights. ↑↓ move the selection (the graph's highlight follows); a route longer than the dialog **scrolls horizontally with ←→**, the whole line shifting - under a ``…`` at whichever edge continues, and snaps back the moment the selection - moves on. + under a cracked chip at whichever edge continues, and snaps back the moment the + selection moves on. A row you are *not* on is cut the same way — it just cannot slide. Nothing here transmits; like the service beneath it, this is a read-model over what the radio already heard. @@ -40,7 +42,16 @@ from rich.text import Text from ..core.models import ChatMessage from ..services.message_paths import Arrival from .pathgraph import PathLayer, render_path_graph, revisited_hops -from .pathline import ELIDE_HEAD, ELIDE_TAIL, cut_mark, path_line +from .pathline import ( + ELIDE_HEAD, + ELIDE_TAIL, + SELF_GLYPH, + PathHop, + PathLine, + cut_mark, + cut_to, + path_line, +) from .theme import snr_style from .tui.render import crop_cells, render_to_ansi from .tui.screen import Screen @@ -209,7 +220,10 @@ class MessagePathsScreen(Screen): else: row = Text(" ") row.append_text(self._path_text(arrival)) - lines.append(render_to_ansi(row, width, no_wrap=True)) + # Cut here rather than letting the render boundary ellipsize it: an + # unselected route runs off the lane exactly as the selected one does, + # and it owes the reader the same cracked chip rather than three dots. + lines.append(render_to_ansi(cut_to(row, width), width, no_wrap=True)) detail = Text(" " * _DETAIL_INDENT) detail.append_text(self._detail_text(arrival)) lines.append(render_to_ansi(detail, width, no_wrap=True)) @@ -219,7 +233,7 @@ class MessagePathsScreen(Screen): # -- the rows -- def _path_text(self, arrival: Arrival) -> Text: - """One arrival's relay chain — the row's first line, and the one you pick. + """One arrival's whole route — the row's first line, and the one you pick. The route gets the row's full width to itself, so no ``via`` introduces it: on a lane that holds nothing else there is nothing to tell it apart from. It is THE @@ -230,12 +244,36 @@ class MessagePathsScreen(Screen): "this is a name" signal), and no hop repeats its hash after its name. The hash bytes are the graph's job, one row up; what ties the two together is the shared node hue, not a second spelling of the hex. + + The line runs **origin to us**, not relay to relay (JP, 2026-08-09): a path's + ends are the nodes it went between, and a chain that opens on its first *relay* + reads as a route from a node that only passed the message on. So the sender leads + (:meth:`_origin_hop`) and we close it on the app-wide ``★``, which is exactly what + the graph one row up draws between — the two now name the same two endpoints + instead of the row starting a hop later than the picture above it. A hopless + arrival is no longer a lone word: ``Alice ▶ ★`` *is* what direct delivery looks + like, and it reads on the same rails as every other row. """ - return path_line( + relays = path_line( arrival.hops, self._resolve, prefix_bytes=self._prefix_bytes, self_name=self._self_name, - empty="direct", hash_as_name=True, - ).text() + hash_as_name=True, + ).hops + return PathLine([self._origin_hop(), *relays, PathHop(SELF_GLYPH, you=True)]).text() + + def _origin_hop(self) -> PathHop: + """The node the message set out from — the route's true head. + + The same origin the graph's left endpoint draws (:func:`~meshterm.ui.widgets. + route_graph_style`), named and hued the same way: the sender's display name in its + key-derived hue, our own ``★`` for a message we sent, and a bare ``?`` where the + frame named nobody — never a hue guessed from a name we can't place. + """ + if not self._source: + return PathHop("?") + if self._self_name and self._source == self._self_name: + return PathHop(SELF_GLYPH, you=True) + return PathHop(self._source, key=self._key_of(self._source) if self._key_of else None) def _detail_text(self, arrival: Arrival) -> Text: """One arrival's reception facts — when it landed, how loudly, which copy. diff --git a/meshterm/ui/node_detail_screen.py b/meshterm/ui/node_detail_screen.py index 4ca754e..4a884d8 100644 --- a/meshterm/ui/node_detail_screen.py +++ b/meshterm/ui/node_detail_screen.py @@ -78,7 +78,7 @@ from rich.cells import cell_len from rich.text import Text from ..core.geo import EARTH_RADIUS_KM, usable_fix -from ..core.models import LOCAL_DEVICE_LABEL, NODE_TYPE_LABELS, Contact, utcnow +from ..core.models import NODE_TYPE_LABELS, Contact, utcnow from ..platforms import Platform, on_platform from .mapcanvas import RGB from .minimap import MiniMap @@ -95,7 +95,15 @@ from .pathgraph import ( ) from .theme import mark_rgb, name_style, snr_style from .tui.render import crop_cells, render_hanging, render_lines, render_to_ansi -from .pathline import ELIDE_HEAD, ELIDE_TAIL, PathHop, PathLine, cut_mark, cut_to +from .pathline import ( + ELIDE_HEAD, + ELIDE_TAIL, + SELF_GLYPH, + PathHop, + PathLine, + cut_mark, + cut_to, +) from .tui.screen import CANCEL, ListWindow, Screen from .widgets import ( _DEFAULT_GLYPH, @@ -1431,7 +1439,10 @@ def _route_line( :class:`~meshterm.ui.pathline.PathLine` drawn exactly as the Message paths dialog draws its arrivals (JP, 2026-08-09): **hops read as names** — every hop the resolver can place wears its contact name in that node's key-derived hue, as powerline chips where the - terminal can draw them, and our own end takes the ``you`` white. A hop nobody can name has + terminal can draw them, and our own end stands on the app-wide ``★`` — every route on this + screen ends on us, so the star says it in one cell and leaves the rest of the lane to the + hops that differ from row to row (the same trade the trace route lane and the trophy card + make, and the same mark the graph above plants on our end). A hop nobody can name has no name to show, so it stands in its own hash at the device's path-hash-mode width (the width the radio itself carries per hop) in the app-wide unknown-node grey — colour being the "this is a name" signal, exactly as in the graph above. No hop repeats its hash after @@ -1457,7 +1468,7 @@ def _route_line( PathHop(node_label, key=name_key) if node_known and name_key else PathHop(_hop_hash(name_key, node_label, hash_bytes)), *(hop_of(hop) for hop in reversed(hops_out)), - PathHop(self_name or LOCAL_DEVICE_LABEL, you=True), + PathHop(SELF_GLYPH, you=True), ]).text() atoms: list[Text] = [] diff --git a/meshterm/ui/path_composer.py b/meshterm/ui/path_composer.py index 4b206dd..c952b77 100644 --- a/meshterm/ui/path_composer.py +++ b/meshterm/ui/path_composer.py @@ -73,7 +73,7 @@ from ..services.topology import ( from .theme import snr_style from .tui.render import query_line, render_lines, render_to_ansi from .tui.screen import ListWindow, Screen -from .pathline import PathLine, path_line +from .pathline import PathLine, cut_to, path_line from .widgets import NodeResolver, _age_seconds, _format_age, _identity, path_text #: Sentinel spec meaning "no forced path — let the device route" (the trace screen's @@ -612,7 +612,10 @@ class PathComposerScreen(Screen): if is_sel: text.style = "brand" text.no_wrap = True - text.truncate(width, overflow="ellipsis") + # A suggestion row names a node in the route's own vocabulary, so it is cut + # like one: a chip that runs off the dialog cracks, prose keeps the ellipsis. + text = cut_to(text, width) + text.no_wrap = True entries.append((i, render_to_ansi(text, width))) if not any(kind == "hop" for kind, _ in rows): if any(kind == "fetch" for kind, _ in rows): diff --git a/meshterm/ui/pathline.py b/meshterm/ui/pathline.py index 78265b2..11a16a6 100644 --- a/meshterm/ui/pathline.py +++ b/meshterm/ui/pathline.py @@ -128,8 +128,8 @@ _ELLIPSIS = "…" #: the page takes the right); :data:`CRACK_HEAD` is its mirror, opening a line whose #: start is off-screen. Only chips crack — :func:`cut_mark` finds a fill or falls back to #: :data:`_ELLIPSIS`, so arrow lines are untouched by the whole idea. -CRACK_TAIL = "▌" -CRACK_HEAD = "▐" +CRACK_TAIL = "▐" +CRACK_HEAD = "▌" #: 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 diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index 3513060..26b4624 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -15,6 +15,7 @@ from typing import Any, Callable, Optional, Union from rich.cells import cell_len from rich.text import Text +from ..pathline import cut_to from .render import crop_cells, query_line, render_lines, render_to_ansi from .screen import LazyLines, Screen @@ -611,8 +612,11 @@ class SelectScreen(Screen): text.append_text(label_text) text.style = style text.no_wrap = True - text.overflow = "ellipsis" - text.truncate(width) + # Cut rather than truncate, so a row carrying a path line (a trophy walk, a + # probe candidate) breaks its chip off on the crack while every ordinary row + # still ends in the ellipsis — ``cut_to`` decides that from the row itself. + text = cut_to(text, width) + text.no_wrap = True return render_to_ansi(text, width) return draw diff --git a/meshterm/ui/tx_screen.py b/meshterm/ui/tx_screen.py index 37ac3c2..5002154 100644 --- a/meshterm/ui/tx_screen.py +++ b/meshterm/ui/tx_screen.py @@ -41,7 +41,7 @@ from ..core.connection import REMOTE_TX_MAX, REMOTE_TX_MIN from ..core.models import Contact, TraceResult, TxLevelResult, TxOptResult from ..services import trace_runner, tx_optimizer from ..services.topology import build_topology, render_custom_spec -from .pathline import PathHop, PathLine, path_line +from .pathline import SELF_GLYPH, PathHop, PathLine, cut_to, path_line from .theme import name_style, snr_style from .trace_screen import TracingDialog, _collapse_trace_width, snr_bar from .tui.render import render_lines, render_to_ansi @@ -352,7 +352,10 @@ class TxSweepScreen(Screen): selected = i == self._index text = self._action_text(key, selected) text.no_wrap = True - text.truncate(width, overflow="ellipsis") + # The route row carries a path line, so it is cut rather than truncated: a + # chip that runs off the row cracks, every other row keeps the ellipsis. + text = cut_to(text, width) + text.no_wrap = True if selected: self._cursor = len(lines) lines.append(render_to_ansi(text, width)) @@ -446,11 +449,16 @@ class TxSweepScreen(Screen): """The walk one measurement makes: out through the tuned link, mirrored home. The outbound leg — us, any composed hops, the tuned node, the target — draws - in full colour (each name in its own key hue, us the white ``you``); the - return (the outbound mirrored back, the trace boomerang the optimizer - actually flies) is dimmed, reading as "not yours to compose". + in full colour (each name in its own key hue); the return (the outbound + mirrored back, the trace boomerang the optimizer actually flies) is dimmed, + reading as "not yours to compose". + + Both our ends stand on the app-wide ``★`` rather than our name: every + measurement this screen makes leaves us and comes home to us, so spelling + ourselves out twice per line would cost the lane the very hops the sweep is + tuning — the same trade the trace route lane and the trophy card make. """ - hops: list[PathHop] = [PathHop(self._device_label, you=True)] + hops: list[PathHop] = [PathHop(SELF_GLYPH, you=True)] for hop in self.route_hops: hops.append(PathHop(self._hop_name(hop), key=hop)) hops.append(PathHop(self._admin_label, key=self._admin_key)) @@ -458,7 +466,7 @@ class TxSweepScreen(Screen): hops.append(PathHop(self._admin_label, key=self._admin_key, dim=True)) for hop in reversed(self.route_hops): hops.append(PathHop(self._hop_name(hop), key=hop, dim=True)) - hops.append(PathHop(self._device_label, you=True, dim=True)) + hops.append(PathHop(SELF_GLYPH, you=True, dim=True)) return PathLine(hops) def _hop_name(self, hop: str) -> str: diff --git a/meshterm/ui/walk_screen.py b/meshterm/ui/walk_screen.py index 1e9fd41..c719b2a 100644 --- a/meshterm/ui/walk_screen.py +++ b/meshterm/ui/walk_screen.py @@ -66,7 +66,7 @@ from ..services.topology import Link, MeshTopology from .map_render import _SELF, _UNKNOWN from .mapcanvas import RGB, MapCanvas, parse_hex from .menus import fit_cells -from .pathline import ELIDE_HEAD, PathHop, PathLine +from .pathline import ELIDE_HEAD, SELF_GLYPH, PathHop, PathLine from .theme import mark_rgb, name_style, snr_style from .trace_screen import snr_bar from .tui.render import query_line, render_to_ansi @@ -667,12 +667,19 @@ class WalkScreen(Screen): return limit def _trail_hop(self, node: str) -> PathHop: - """One walked step as a path hop: its display name in its own identity colour.""" + """One walked step as a path hop: its display name in its own identity colour. + + Our own node takes the app-wide :data:`~meshterm.ui.pathline.SELF_GLYPH` instead + of its name: every walk sets out from us, so the star says in one cell what the + trail's most width-starved line would otherwise spend a whole name on — and the + cells it frees are steps that stay in view before the head has to be elided. + """ style = self._list_name_style(node) + if style == "you": + return PathHop(SELF_GLYPH, you=True) return PathHop( self._label(node), - key=None if style in ("you", "node.unknown") else node, - you=style == "you", + key=None if style == "node.unknown" else node, ) def _focus_line(self, depths: dict[str, int]) -> Text: diff --git a/tests/test_live_screens.py b/tests/test_live_screens.py index 982f422..10a5502 100644 --- a/tests/test_live_screens.py +++ b/tests/test_live_screens.py @@ -1020,7 +1020,12 @@ def test_sweep_screen_stars_the_running_best() -> None: screen.on_level(1, 7, _level(12, -2.0)) screen.on_level(2, 7, _level(18, 7.5)) assert screen.phase_label() == "coarse sweep · level 2/7" - starred = next(line for line in _plain(screen.render_body(100)).splitlines() if "★" in line) + # The route lane wears ★ for our own ends, so the star that marks the winner is the + # one in the levels table — a row that also carries the level's reading. + starred = next( + line for line in _plain(screen.render_body(100)).splitlines() + if "★" in line and "dB" in line + ) assert "18" in starred diff --git a/tests/test_livefeed.py b/tests/test_livefeed.py index 8aba6d3..15a1c4d 100644 --- a/tests/test_livefeed.py +++ b/tests/test_livefeed.py @@ -206,7 +206,9 @@ def test_livefeed_addressed_frame_is_about_its_two_ends() -> None: screen.on_event(MeshEvent.observation_event(_obs(node="", kind="packet", raw=raw))) row = _rows(screen, 100)[0] assert "📩 direct message" in row - assert "Alice → Us" in row # …including us, when a frame is addressed to us + # …including us, when a frame is addressed to us — as the app-wide ★, which is the one + # endpoint the reader never has to be told and the cells the other end's name needs. + assert "Alice → ★" in row # A pair too wide for the lane spends its cells on the addressee: the sender drops to # the hash it was named by rather than the recipient being the half cut off. diff --git a/tests/test_message_paths_screen.py b/tests/test_message_paths_screen.py index c90e9ec..11ceec6 100644 --- a/tests/test_message_paths_screen.py +++ b/tests/test_message_paths_screen.py @@ -208,12 +208,61 @@ def test_paths_screen_cracks_the_chips_the_scroll_cuts(monkeypatch) -> None: # assert row.rstrip().endswith(CRACK_TAIL) and "…" not in row +def test_paths_screen_route_runs_origin_to_us_not_relay_to_relay() -> None: + """A row is the whole route, not the relay chain it rode. + + A path's ends are the nodes it went *between*, so the sender leads the line and we + close it on the ★ — the same two endpoints the graph one row up draws between, which + is what lets the row and the picture cross-read. A chain that opened on its first + relay read as a route from a node that had only passed the message on. + """ + screen = _screen(_arrivals()) + rows = _plain(screen.render_body(76)).split("sensor\n\n")[1].split("\n") + assert rows[0] == "❯ Alice → YUL-Cartierville → ★" + assert rows[2] == " Alice → Waymarker → 77 → ★" # an unnamed relay still stands in its hash + + +def test_paths_screen_origin_is_a_star_for_us_and_a_question_for_nobody() -> None: + """The head is named exactly as the graph's left endpoint is: our own ``★`` on a + message we sent, a bare ``?`` where the frame named nobody — never a guessed name.""" + now = utcnow() + one = [Arrival(when=now, hops=("3d63",), snr=1.0)] + ours = _plain(_screen(one, source="Homestead").render_body(76)) + assert "❯ ★ → YUL-Cartierville → ★" in ours + nameless = _plain(_screen(one, source=None).render_body(76)) + assert "❯ ? → YUL-Cartierville → ★" in nameless + + +def test_paths_screen_cuts_unselected_rows_the_same_way(monkeypatch) -> None: # noqa: ANN001 + """A row you are not on runs off the lane exactly as the selected one does, so it owes + the reader the same cracked chip — it just cannot slide to read the rest.""" + monkeypatch.setattr(pathline, "powerline_enabled", lambda: True) + now = utcnow() + long = tuple(f"{i:02x}{i:02x}" for i in range(12)) + screen = _screen([ + Arrival(when=now, hops=long, snr=1.0), + Arrival(when=now + timedelta(seconds=2), hops=long[::-1], snr=2.0), + ]) + lines = _plain(screen.render_body(40)).split("\n") + rows = lines[next(i for i, ln in enumerate(lines) if ln.startswith("❯")):] + assert rows[0].rstrip().endswith(CRACK_TAIL) # the selected row + unselected = rows[2] # row 1 is the selected row's hanging reception facts + assert unselected.rstrip().endswith(CRACK_TAIL) and "…" not in unselected + + def test_paths_screen_direct_arrival_and_empty_state() -> None: - """A hop-less arrival reads direct; no arrivals at all explain themselves.""" + """A hop-less arrival draws its two endpoints and nothing between; no arrivals at all + explain themselves. + + The route no longer collapses to the word *direct*: with both ends on the line, + ``Alice → ★`` **is** what a direct delivery looks like, and it reads on the same rails + as every relayed row instead of swapping the picture for a caption. + """ now = utcnow() screen = _screen([Arrival(when=now, hops=(), snr=2.5)]) body = _plain(screen.render_body(76)) - assert "direct" in body + assert "❯ Alice → ★" in body + assert "direct" not in body empty = _screen([], matched=False) body = _plain(empty.render_body(76)) assert "No direct-message frames logged in the window." in body diff --git a/tests/test_node_detail.py b/tests/test_node_detail.py index 306fb98..6b30b29 100644 --- a/tests/test_node_detail.py +++ b/tests/test_node_detail.py @@ -36,7 +36,7 @@ from meshterm.ui.node_detail_screen import ( _signal_row, ) from meshterm.ui.pathgraph import DST_NODE, SRC_NODE -from meshterm.ui.pathline import CRACK_HEAD, CRACK_TAIL, PathHop, PathLine +from meshterm.ui.pathline import CRACK_HEAD, CRACK_TAIL, SELF_GLYPH, PathHop, PathLine from meshterm.ui.tui.screen import CANCEL from meshterm.ui.widgets import highlighted_hash, route_graph_style, tab_strip @@ -203,7 +203,8 @@ def test_route_line_names_its_hops() -> None: assert line.startswith("Far") # the contact anchors the left, by name assert "Hub" in line # the relay reads as the contact it is, not as ``3d`` assert "3d" not in line and "f2" not in line # no hop repeats its hash after its name - assert line.rstrip().endswith("Us") # our own node anchors the right + assert line.rstrip().endswith(SELF_GLYPH) # our own node anchors the right, as the ★ + assert "Us" not in line # …in one cell, not a name every row would repeat assert context.plain == "device route" # the firmware-route tag, off the pathline itself @@ -257,8 +258,7 @@ def test_route_line_direct_route_has_no_relay() -> None: resolve=make_node_resolver([HUB]), node_known=True, self_name="Us", hash_bytes=1, ) - line = path.plain - assert "Far" in line and "Us" in line and "→" in line + assert path.plain == f"Far → {SELF_GLYPH}" # --- the routes view (list + graph callbacks) ----------------------------------- diff --git a/tests/test_tui.py b/tests/test_tui.py index 366d069..3faa0b7 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -21,6 +21,7 @@ from rich.text import Text from meshterm import copyright_notice from tests.conftest import plain as _plain from meshterm.ui.menus import section_heading +from meshterm.ui.pathline import CRACK_TAIL, PathHop, PathLine from meshterm.ui.tui import frame, glow from meshterm.ui.tui.glow import apply_corner_glow from meshterm.ui.tui.progress import ProgressScreen @@ -208,6 +209,22 @@ def test_select_width_aware_title_fits_itself_to_the_row() -> None: assert screen._rows() == screen._items +def test_select_row_cracks_a_chip_path_and_ellipsizes_everything_else() -> None: + """A row too wide for the list is *cut*, not truncated: a row carrying a path line + (a trophy walk, a probe candidate) breaks its chip off on the crack, while an + ordinary prose row keeps the ellipsis. The row itself decides which — the list has + no idea it is ever holding a route.""" + route = PathLine( + [PathHop(f"NODE{i:02d}", key=f"{i:02x}aa") for i in range(8)], mode="powerline" + ).text() + screen = SelectScreen("pick", [Choice(route, 1), Choice("a plainly worded row", 2)]) + rows = [ln for ln in _plain(screen.render_body(16)).split("\n") if ln.strip()] + route_row = next(ln for ln in rows if "NODE" in ln) + prose_row = next(ln for ln in rows if "plainly" in ln) + assert route_row.rstrip().endswith(CRACK_TAIL) and "…" not in route_row + assert prose_row.rstrip().endswith("…") # prose was shortened; that is what happened + + def test_select_hscroll_highlight_keeps_the_natural_row() -> None: """In an hscroll list the highlighted row skips self-fitting — ←→ slide the full line — while every other row still elides itself to the width.""" diff --git a/tests/test_walk.py b/tests/test_walk.py index 38a54c5..f5f1b15 100644 --- a/tests/test_walk.py +++ b/tests/test_walk.py @@ -11,6 +11,7 @@ from datetime import timedelta from meshterm.core.models import Contact, utcnow from meshterm.services.topology import MeshTopology +from meshterm.ui.pathline import SELF_GLYPH from meshterm.ui.tui.render import render_to_ansi from meshterm.ui.walk_screen import WalkScreen @@ -210,7 +211,7 @@ def test_walk_enter_walks_and_grows_the_trail() -> None: screen.handle("enter") # walk to YUL (our only neighbour) assert screen._focus == topo.canonical(YUL.public_key) body = _plain(screen.render_body(80)) - assert "Homestead › YUL-Cartierville" in body # the trail + assert "★ › YUL-Cartierville" in body # the trail, our end on the app-wide star # The focus line reads name (hash) — the glyph carries the type, not a spelled-out kind. assert "YUL-Cartierville (3d)" in body and "1 hop out" in body assert "Alice" in body # YUL's onward neighbour is now a row @@ -303,7 +304,7 @@ def test_walk_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" + assert screen._trail_text(40).plain == SELF_GLYPH # us, in one cell def test_walk_trail_names_carry_their_node_hues() -> None: @@ -669,7 +670,7 @@ def test_walk_trail_scroll_stops_at_the_head_and_resets_with_the_trail() -> None screen.handle("left") assert screen._trail_scroll == limit head = _plain([render_to_ansi(screen._trail_text(width), width, no_wrap=True)]) - assert "Homestead" in head # scrolled far enough to read where the walk set out from + assert head.lstrip().startswith(SELF_GLYPH) # scrolled far enough to see where it set out screen.handle("backspace") # stepping back is a change to the trail assert screen._trail_scroll == 0