diff --git a/CLAUDE.md b/CLAUDE.md index 5decfcd..afa2e24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -264,12 +264,18 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow same fill are the exception the interlock can't draw: the point drops its background so the page cuts the wedge. An elision breaks the ribbon rather than joining it — bare `⋯` on the page between a closing point and the next chip's notch, no fill, no padding. -- The ribbon's **outer ends**: the chevron means *the route continues*, so a path that has - finished never wears one. It ends **square** — nothing appended, the last chip's own pad - is the edge — and opens square the same way; a full Nerd Font rounds both ends into a - lozenge instead (`POWERLINE_ROUND_*`, drawn only where `termfont.powerline_full`). A - *wrapped* line the path outruns still closes on the point: there the route really does - go on, which is the cue arrow mode spells with a trailing `→`. +- The ribbon's **outer ends**: the chevron means *the route continues*, so an end that is + the route's own never wears one. It ends **square** — nothing appended, the last chip's + own pad is the edge — and opens square the same way; a full Nerd Font rounds such ends + into a lozenge instead (`POWERLINE_ROUND_*`, drawn only where `termfont.powerline_full`). + Two things put the chevron back, and either is enough. A *wrapped* line the path outruns + closes on the point: there the route really does go on. And a line drawing only the + **middle** of a route — a packet's `via` chain, the TX sweep's composed relays: hops that + are neither the node the frame came from nor the one it reached — opens on the notch and + closes on the point, because a square end is the promise that the chip beside it is + where the route began or ended (`PathLine(from_origin=…, to_destination=…)`, defaulting + to a whole route). Arrow mode spells the same claim with the separator alone, a leading + or trailing `→` with no hop on the far side of it. - 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/packet_viewer.py b/meshterm/ui/packet_viewer.py index 3f46e2b..22a024e 100644 --- a/meshterm/ui/packet_viewer.py +++ b/meshterm/ui/packet_viewer.py @@ -54,7 +54,7 @@ from ..services.trace_runner import NodeResolver from .map_render import _SELF, _UNKNOWN from .menus import SEP_COMPACT, SEP_ROOMY from .pathgraph import PathLayer, render_path_graph, revisited_hops -from .pathline import PathLine, path_line +from .pathline import PathLine, hops_atom, path_line from .theme import glyph, name_style, snr_style from .trace_screen import snr_bar from .tui.render import render_lines, render_to_ansi @@ -869,6 +869,12 @@ class PacketViewer(Screen): with an empty path and a full set of readings, and "direct — no relays" would be a flat contradiction of the ``links`` row two lines above. It says how far the packet got instead, which is the one thing those readings do establish. + + The chain is the route's *middle* — the ``path`` field names the relays that + forwarded the frame and neither the node that sent it nor the one that received + it — so both ends are drawn open (``from_origin``/``to_destination``): the + chevron says the route carries on past the chip, where a flat end would claim + the first relay was the origin. """ return path_line( (entry.path or "").split(","), @@ -876,6 +882,8 @@ class PacketViewer(Screen): prefix_bytes=self._prefix_bytes, self_name=self._self_name, empty=self._empty_via(entry), + from_origin=False, + to_destination=False, ) @staticmethod @@ -904,26 +912,15 @@ class PacketViewer(Screen): if route: rows.append(("route", Text(route.replace("_", " ").lower()))) rows.append(("via", self._via_path(entry))) - # Which node the SNR beside it actually measured. On a relayed frame that is the - # last repeater, and saying so stops the figure being read as the origin's signal; - # on one that crossed nothing it *is* the origin's, which is worth saying outright - # — a frame heard straight off its sender is the best evidence a link ever gives. - # A trace that walked counts as relayed even with no hops to name: its readings - # are the proof something forwarded it (see :meth:`_via_path`). - relayed = bool( - [hop for hop in (entry.path or "").split(",") if hop] or raw.get("trace_snrs") - ) - rows.append( - ( - "", - Text( - "reception describes the last relay, not the origin" - if relayed - else "reception describes the sender itself — nothing relayed it", - style="faint", - ), - ) - ) + # The figure the chain above encodes but never states, hung under it as the app's + # own hop atom — the same one the node page's routes and the trace scenarios carry + # (:func:`~meshterm.ui.pathline.hops_atom`), so a count reads the same wherever a + # path has one. Only where there is a chain to count: an empty ``path`` has already + # been spelt out in words on the row above (:meth:`_empty_via`), and a second line + # reading ``direct`` under ``direct — no relays`` would be the same answer twice. + relays = [hop for hop in (entry.path or "").split(",") if hop] + if relays: + rows.append(("", hops_atom(len(relays)))) # A chain that names one hop twice reads as a mistake until it is explained; the graph # below draws that hop twice too (see ``_graph_lines``), so the note covers both. revisits = revisit_note( diff --git a/meshterm/ui/pathline.py b/meshterm/ui/pathline.py index f5f4711..afc36d1 100644 --- a/meshterm/ui/pathline.py +++ b/meshterm/ui/pathline.py @@ -22,7 +22,13 @@ drawn*, so every surface the survey found can eventually route through it: that name. A chip caught by a *cut* — a lane that ran out, a line scrolled past its edge — breaks off on a half block in its own fill (:func:`cut_mark`), and that crack is what says the segment continues; only chips - crack, an arrow line still ellipsizes. A row that also carries the app-wide + crack, an arrow line still ellipsizes. A line's two *outer* ends read the same way: + a path that begins at its origin and ends at its destination opens and closes flat (a + rounded cap where the font has one), while one drawing only the *middle* of a route — + a packet's ``via`` chain, which names relays and neither of the nodes the packet went + between — wears the chevron at that end instead, the same mark a wrapped line uses for + "there is more of this out there" (``from_origin`` / ``to_destination``). + A row that also carries the app-wide opens-further-prompts ``…`` hangs it *outside* the budget (:func:`with_action_mark`) — chrome may not cost the route cells. ``auto`` (the default) picks powerline exactly when the terminal can draw it @@ -405,6 +411,12 @@ class PathLine: surface with its own convention (the mesh walk trail's ``" › "``) may pass it. Ignored by powerline mode. empty: The muted text a hopless path reads as (``"direct"``). + from_origin: The first hop *is* the route's origin. Clear it where the line + draws only the middle of a route — a packet's ``via`` chain names its + relays and neither the node that sent it nor the one that received it — and + the head is drawn as a continuation instead of as a beginning. + to_destination: The last hop *is* the route's destination. Clear it under the + same reading at the other end. """ def __init__( @@ -414,6 +426,8 @@ class PathLine: mode: str = "auto", separator: str = _ARROW, empty: str = "direct", + from_origin: bool = True, + to_destination: bool = True, ) -> None: """Hold the hops and the drawing choices; nothing is measured or drawn yet. @@ -423,6 +437,8 @@ class PathLine: self._mode = mode self._separator = separator self._empty = empty + self._from_origin = from_origin + self._to_destination = to_destination @property def hops(self) -> list[PathHop]: @@ -616,14 +632,22 @@ class PathLine: """ if plain: plain_cells = [self._plain_hop(hop).cell_len for hop in hops] - return plain_cells, cell_len(self._separator), 0, 0, 0, 0 + head = 0 if self._from_origin else cell_len(self._separator.lstrip()) + foot = 0 if self._to_destination else cell_len(self._separator.rstrip()) + return plain_cells, cell_len(self._separator), 0, 0, head, foot widths = [ cell_len(hop.label) if hop.gap else self._chip(hop, self._chip_fill(hop)).cell_len for hop in hops ] sep = cell_len(POWERLINE_SEP) cap = cell_len(POWERLINE_ROUND_OPEN) if powerline_full() else 0 - return widths, sep, sep, sep, cap, cap + # An end the route runs on past costs its chevron whatever the font can draw: + # the point and the notch are core glyphs, and it is the *cap* that is optional. + # So a relays-only line is measured a cell wider at that end than a whole route + # is on a terminal with no rounded caps to spend. + head = cap if self._from_origin else sep + foot = cap if self._to_destination else sep + return widths, sep, sep, sep, head, foot @staticmethod def _fill( @@ -797,17 +821,33 @@ class PathLine: if not force_plain and self._resolved_mode() == "powerline": line = self._render_chips(hops, carry_in=carry_in, carry_on=carry_on) else: - line = self._render_plain(hops) + line = self._render_plain(hops, carry_in=carry_in, carry_on=carry_on) line.style = PATH_INK return line - def _render_plain(self, hops: list[PathHop]) -> Text: - """Arrow-joined hops — ``path_text``'s presentation, hop by hop.""" + def _render_plain( + self, hops: list[PathHop], *, carry_in: bool = False, carry_on: bool = False + ) -> Text: + """Arrow-joined hops — ``path_text``'s presentation, hop by hop. + + An end the route runs on past wears the separator with nothing on the far side + of it: a leading ``→`` where the origin isn't drawn, a trailing one where the + destination isn't. Arrow mode already spells "the path goes on" with that + trailing mark when a line wraps, so a chain of relays says it the same way — + which is what keeps the two modes telling the reader the same thing on a console + with no chevrons to shear (see :meth:`_render_chips`). The wrap flags win where + they overlap: a continuation already opens under its predecessor, and + :meth:`wrapped` appends the cue itself on a line the path outruns. + """ text = Text() + if hops and not carry_in and not self._from_origin and not hops[0].gap: + text.append(self._separator.lstrip(), style="muted") for i, hop in enumerate(hops): if i: text.append(self._separator, style="faint" if hop.dim else "muted") text.append_text(self._plain_hop(hop)) + if hops and not carry_on and not self._to_destination and not hops[-1].gap: + text.append(self._separator.rstrip(), style="muted") return text def _plain_hop(self, hop: PathHop) -> Text: @@ -845,19 +885,27 @@ class PathLine: ) -> Text: """Powerline chips: each hop filled with its hue, a two-cell gap at every seam. - The two outer ends say whether this line *is* the path or only part of it. A - line that opens the path opens rounded; one that continues a wrapped path opens - on the break's other half — which is exactly the seam's own second cell, so a - fold looks like the seam it interrupted. A line that ends the path closes - rounded; one the path outruns closes on the point, the same "goes on" cue arrow - mode spells with a trailing ``→``. + The two outer ends say whether the reader is looking at the whole route, and + two separate things can shorten it. **This line** may be one of several a wrapped + path folds into (``carry_in``/``carry_on``), and **the path itself** may only ever + have been the middle of a route — a packet's ``via`` chain names its relays and + neither of the nodes it actually went between (``from_origin``/``to_destination`` + on the line). Either one draws the same mark at that end, because the reader is + being told the same thing: *the route goes on out there, past what is drawn*. A + head like that opens on the break's other half — the seam's own second cell, so a + fold looks like the seam it interrupted, and a relays-only chain looks like a + route caught mid-stride. A tail like that closes on the point, the same "goes on" + cue arrow mode spells with a trailing ``→``. - Where the font has no rounded caps both outer ends are drawn **square** — the - chip's own pad is the edge, and nothing is appended. Square is not a degraded - cap, it is the one shape left that says *stop*: the point is spoken for as the - continuation cue, so closing a finished path on one claimed a hop had been cut - off (JP, 2026-09-09). Only the *opening* used to square itself off, which left - every route on a core-only terminal ending on the mark for "there is more". + An end that really is the route's own opens or closes **rounded**, and, where the + font has no rounded caps, **square** — the chip's own pad is the edge, and nothing + is appended. Square is not a degraded cap, it is the one shape left that says + *stop*: the point is spoken for as the continuation cue, so closing a finished + path on one claimed a hop had been cut off (JP, 2026-09-09). Only the *opening* + used to square itself off, which left every route on a core-only terminal ending + on the mark for "there is more". The pair now reads both ways round — a square end + is a promise that the node beside it is where the route began or ended, which is + why a chain that starts and finishes on relays must not wear one (JP, 2026-09-09). An elision (:attr:`PathHop.gap`) interrupts the ribbon rather than joining it: the chip before it closes on the page, the mark sits on the page bare, and the chip @@ -871,8 +919,12 @@ class PathLine: """ fills = [self._chip_fill(hop) for hop in hops] rounded = powerline_full() + # A fold and a half-drawn route are two reasons for the same mark; either is + # enough. An elision already breaks the ribbon on that side, and a mark cut out + # of a bare ``⋯`` would have no fill to cut into. + opens_mid = (carry_in or not self._from_origin) and not hops[0].gap text = Text() - if carry_in: + if opens_mid: text.append_text(self._notch(fills[0])) # the break's other half elif rounded and not hops[0].gap: text.append(POWERLINE_ROUND_OPEN, style=fills[0]) @@ -890,7 +942,7 @@ class PathLine: text.append_text(self._chip(hop, fills[i])) if hops[-1].gap: return text # the line ended on the page; there is no chip left to close - if carry_on: + if carry_on or not self._to_destination: text.append(POWERLINE_SEP, style=fills[-1]) # the point: the path goes on elif rounded: text.append(POWERLINE_ROUND_CLOSE, style=fills[-1]) # the lozenge's far end @@ -1019,6 +1071,8 @@ def path_line( dim_self: bool = True, cursor: int | None = None, mode: str = "auto", + from_origin: bool = True, + to_destination: bool = True, ) -> PathLine: """Build a :class:`PathLine` from raw hop hashes — ``path_text``'s vocabulary. @@ -1067,6 +1121,10 @@ def path_line( as ``dim_from`` is, and applied after them, so a slot never shifts what a hop shows or fades. ``None`` draws no cursor. mode: The :class:`PathLine` mode (``"auto"``/``"powerline"``/``"plain"``). + from_origin: The first hop is the route's origin (see :class:`PathLine`). Clear + it where ``hops`` is a chain of *relays* — the raw ``path`` field a packet + carries names neither the node it came from nor the one it reached. + to_destination: The last hop is the route's destination, same reading. Returns: The assembled :class:`PathLine`. @@ -1116,4 +1174,10 @@ def path_line( built.append(PathHop(compact)) if cursor is not None: built.insert(max(0, min(cursor, len(built))), PathHop(CURSOR_GLYPH, cursor=True)) - return PathLine(built, mode=mode, empty=empty) + return PathLine( + built, + mode=mode, + empty=empty, + from_origin=from_origin, + to_destination=to_destination, + ) diff --git a/meshterm/ui/tx_screen.py b/meshterm/ui/tx_screen.py index 439b547..d3036c5 100644 --- a/meshterm/ui/tx_screen.py +++ b/meshterm/ui/tx_screen.py @@ -391,8 +391,19 @@ class TxSweepScreen(Screen): if key == "route": _icon(text, "✎", "brand") if self.route_hops: + # The composed relays alone — the sweep always sets out from us and always + # lands on the repeater being tuned, and neither is a hop anyone picked + # here. So both ends are drawn open: the chevrons say the route runs on + # past them, which is exactly what ``via`` claims in the word before it. text.append("Route — via ") - text.append_text(path_line(list(self.route_hops), self._resolve).text()) + text.append_text( + path_line( + list(self.route_hops), + self._resolve, + from_origin=False, + to_destination=False, + ).text() + ) else: text.append(f"Route — direct to {self._admin_label}") elif key == "range": diff --git a/tests/test_packet_viewer.py b/tests/test_packet_viewer.py index 992fd75..054e2b9 100644 --- a/tests/test_packet_viewer.py +++ b/tests/test_packet_viewer.py @@ -309,7 +309,7 @@ def test_packet_viewer_via_wraps_at_hop_boundaries_under_its_own_lane() -> None: entry = PacketEntry(when=utcnow(), kind="packet", path=",".join(names)) lines = _stripped(PacketViewer([entry], 0, resolve=lambda h: names.get(h, "")).render_body(72)) via_at = next(i for i, line in enumerate(lines) if line.startswith("via")) - end = next(i for i, line in enumerate(lines) if "reception describes" in line) + end = next(i for i, line in enumerate(lines) if line.strip() == "8 hops") folded = lines[via_at:end] assert len(folded) > 1 # a chain this long does not fit one lane assert all(line.startswith(" " * 11) for line in folded[1:]) # hangs past the label lane @@ -319,6 +319,20 @@ def test_packet_viewer_via_wraps_at_hop_boundaries_under_its_own_lane() -> None: assert all(len(line.rstrip()) <= 72 for line in folded) +def test_packet_viewer_via_chain_is_drawn_as_the_middle_of_a_route() -> None: + """``via`` names relays, so neither end of it is where the frame set out or arrived. + + The chain opens and closes on the "there is more out there" mark — the chevron in + chips, the bare separator in arrows — rather than on the flat end that would claim + the first relay was the origin. + """ + entry = PacketEntry(when=utcnow(), kind="packet", node="c0ffee", path="a1b2c3,d4e5f6") + via = next(ln for ln in _stripped(_viewer(entry).render_body(80)) if ln.startswith("via")) + chain = via.split(None, 1)[1].strip() + assert chain.startswith("→ ") and chain.endswith(" →") + assert chain.count("→") == 3 # both open ends, and the one join between the two relays + + def test_packet_viewer_from_row_leads_with_the_node_type_mark() -> None: """The sender wears its shared node glyph — typed by the packet, by the contacts, or ``○``.""" from meshterm.core.models import NODE_TYPE_REPEATER @@ -491,12 +505,13 @@ def test_packet_viewer_omits_links_for_a_trace_nobody_relayed() -> None: assert "links" not in _plain(_viewer(entry).render_body(80)) -def test_packet_viewer_says_whose_signal_the_reading_is() -> None: - """The SNR beside a frame measures its last transmitter — which is not always a relay. +def test_packet_viewer_counts_the_relays_under_the_chain() -> None: + """The hop count hangs under the ``via`` chain — the figure the chips never state. - A relayed frame's reading belongs to the repeater that passed it on, and reading it as - the origin's would be wrong. One that crossed nothing was heard straight off its - sender, so the reading is exactly that link — the strongest evidence there is. + It is the app's own hop atom, so a count here reads as it does on the node page's + routes and under a trace scenario. An empty chain has no count: the ``via`` row has + already said "direct — no relays" in words, and ``direct`` under it would be the same + answer twice. """ def note(path: str) -> str: @@ -505,9 +520,10 @@ def test_packet_viewer_says_whose_signal_the_reading_is() -> None: ) return _plain(_viewer(entry).render_body(80)) - assert "last relay, not the origin" in note("a1b2c3,d4e5f6") - assert "the sender itself" in note("") - assert "last relay" not in note("") + assert "2 hops" in note("a1b2c3,d4e5f6") + assert "1 hop" in note("a1b2c3") and "1 hops" not in note("a1b2c3") + assert "direct" not in note("a1b2c3") + assert note("").count("direct") == 1 # the via row's own words, and nothing under them def test_packet_viewer_does_not_call_a_walked_trace_direct() -> None: @@ -526,7 +542,6 @@ def test_packet_viewer_does_not_call_a_walked_trace_direct() -> None: body = _plain(_viewer(entry).render_body(80)) assert "3 hops walked" in body assert "direct — no relays" not in body - assert "last relay, not the origin" in body # something *did* relay it def test_packet_viewer_still_calls_an_unwalked_frame_direct() -> None: @@ -538,7 +553,6 @@ def test_packet_viewer_still_calls_an_unwalked_frame_direct() -> None: ).render_body(80) ) assert "direct — no relays" in body, raw - assert "the sender itself" in body, raw def test_packet_viewer_counts_one_walked_hop_in_the_singular() -> None: diff --git a/tests/test_pathline.py b/tests/test_pathline.py index 415024a..65b9038 100644 --- a/tests/test_pathline.py +++ b/tests/test_pathline.py @@ -559,6 +559,85 @@ def test_rounded_caps_finish_a_path_only_where_the_font_has_them(monkeypatch) -> assert all(text.cell_len <= 20 for text in wrapped) +def test_a_line_drawing_only_a_route_middle_wears_the_chevron_at_that_end(monkeypatch) -> None: # noqa: ANN001 + """A chain of relays has no endpoints of its own, so neither end may claim to be one. + + A packet's ``via`` field names the nodes that forwarded it and neither the node it + came from nor the one that received it. A flat end there would say the first relay + *is* where the route began — so the line opens on the notch and closes on the point + instead, the same "there is more of this out there" mark a wrapped line uses. The cap + is what the flags trade away: a full Nerd Font rounds only the ends that are real. + """ + hops = [PathHop(label, key=key) for label, key in (("AAAA", "aa"), ("BBBB", "77"))] + middle = PathLine(hops, mode="powerline", from_origin=False, to_destination=False) + + monkeypatch.setattr(pathline, "powerline_full", lambda: True) + text = middle.text() + assert text.plain.startswith(POWERLINE_SEP + " AAAA") # the notch, not the lozenge + assert text.plain.endswith("BBBB " + POWERLINE_SEP) # the point: the route goes on + opening = next(s for s in text.spans if s.start == 0 and str(s.style) != PATH_INK) + assert str(opening.style) == f"{_style_hex(node_style('aa'))} reverse" # cut out of AAAA + + # One end at a time: the half that *is* an endpoint keeps its cap either way. + head = PathLine(hops, mode="powerline", to_destination=False).text().plain + assert head.startswith(POWERLINE_ROUND_OPEN) and head.endswith(POWERLINE_SEP) + tail = PathLine(hops, mode="powerline", from_origin=False).text().plain + assert tail.startswith(POWERLINE_SEP) and tail.endswith(POWERLINE_ROUND_CLOSE) + + monkeypatch.setattr(pathline, "powerline_full", lambda: False) + squared = PathLine(hops, mode="powerline").text().plain # the whole route, for contrast + assert squared.startswith(" AAAA") and squared.endswith("BBBB ") + assert middle.text().plain.startswith(POWERLINE_SEP) # …the chevron is not the cap + assert middle.text().plain.endswith(POWERLINE_SEP) + + +def test_arrow_mode_says_a_route_middle_with_the_separator_it_joins_with() -> None: + """No chevrons to shear on a console, so the arrow itself carries the same claim. + + A leading ``→`` where the origin is not drawn, a trailing one where the destination + is not — the mark arrow mode already spells "the path goes on" with when a line + wraps. Both modes tell the reader the same thing. + """ + hops = [PathHop(label, key=key) for label, key in (("AAAA", "aa"), ("BBBB", "77"))] + assert PathLine(hops, mode="plain").text().plain == "AAAA → BBBB" + middle = PathLine(hops, mode="plain", from_origin=False, to_destination=False) + assert middle.text().plain == "→ AAAA → BBBB →" + assert PathLine(hops, mode="plain", from_origin=False).text().plain == "→ AAAA → BBBB" + assert PathLine(hops, mode="plain", to_destination=False).text().plain == "AAAA → BBBB →" + + +def test_a_wrapped_route_middle_pays_for_both_its_chevrons() -> None: + """The marks are measured, not appended past the budget — on either mode. + + A relays-only line spends a cell at each outer end that a whole route spends only + where the font has caps, so the fit has to know about them; the wrap flags and the + endpoint flags must not both draw at the same break either. + """ + hops = [ + PathHop(label, key=key) + for label, key in (("AAAA", "aa"), ("BBBB", "77"), ("CCCC", "3d"), ("DDDD", "f2")) + ] + lines = PathLine(hops, mode="powerline", from_origin=False, to_destination=False).wrapped( + 20, indent=2 + ) + assert len(lines) == 2 + assert all(line.cell_len <= 20 for line in lines) + assert lines[0].plain.startswith(POWERLINE_SEP) # opens mid-route… + assert lines[0].plain.endswith(POWERLINE_SEP) # …and the fold goes on + assert lines[-1].plain.endswith(POWERLINE_SEP) # …as does the route past the last chip + # Never both marks at one break: a fold that also happens to be the head or the + # tail of the drawn route still draws one chevron there, not two. + assert POWERLINE_SEP * 2 not in "".join(line.plain for line in lines) + + folded = PathLine(hops, mode="plain", from_origin=False, to_destination=False).wrapped( + 22, indent=2 + ) + assert len(folded) > 1 and all(line.cell_len <= 22 for line in folded) + assert folded[0].plain.startswith("→ AAAA") # the head mark, once + assert folded[-1].plain.rstrip().endswith("→") + assert not folded[-1].plain.strip().startswith("→") # a continuation opens on its hop + + def test_a_seam_between_two_of_one_colour_falls_back_to_the_page() -> None: """Where two chips land on the same fill, the seam falls back to the bare page.