diff --git a/meshterm/ui/trace_screen.py b/meshterm/ui/trace_screen.py index 733f8ee..961c377 100644 --- a/meshterm/ui/trace_screen.py +++ b/meshterm/ui/trace_screen.py @@ -1203,7 +1203,8 @@ class TraceScreen(Screen): Each hop reads ``n origin → destination +4.5 dB meter``, the link drawn through THE path widget as the route lane above draws it: names, no hash after them (an unnamed node shows its hash, the only identity it has), our own end the bare - ``★``. Where every row fits whole the hops are a four-column table, one line each. + ``★``, and only the route's own two ends square (see :meth:`_hop_path`). Where + every row fits whole the hops are a four-column table, one line each. Where any doesn't, every row takes two — all alike, so the column of readings never jumps between lines as the eye runs down it: the hop number and its path on the first, the reading and its meter flush right on the second. A path still @@ -1214,7 +1215,11 @@ class TraceScreen(Screen): the rows are drawn at the shift the footer and the next press agree on. """ numbers = [Text(str(agg.index), style="muted") for agg in stats.hop_snrs] - paths = [self._hop_path(agg, hash_bytes) for agg in stats.hop_snrs] + last = len(stats.hop_snrs) - 1 + paths = [ + self._hop_path(agg, hash_bytes, from_origin=i == 0, to_destination=i == last) + for i, agg in enumerate(stats.hop_snrs) + ] readings = [ Text(f"{agg.median_snr:+.1f} dB", style=snr_style(agg.median_snr)) for agg in stats.hop_snrs @@ -1259,8 +1264,20 @@ class TraceScreen(Screen): lines.append(render_to_ansi(second, width, no_wrap=True)) return lines - def _hop_path(self, agg: HopAggregate, hash_bytes: int | None) -> Text: - """One hop's link, ``origin → destination``, as the route lane draws its nodes.""" + def _hop_path( + self, + agg: HopAggregate, + hash_bytes: int | None, + *, + from_origin: bool, + to_destination: bool, + ) -> Text: + """One hop's link, ``origin → destination``, as the route lane draws its nodes. + + A hop is one link of the walk, so only the route's own two ends are drawn square — + the first hop's start, the last hop's finish. Every other end is a node the route + runs on through, and wears the chevron (the arrow form, a bare ``→``) that says so. + """ ends = [ None if not node or node == self._device_label else node for node in (agg.origin, agg.destination) @@ -1272,6 +1289,8 @@ class TraceScreen(Screen): self_name=self._device_label, hash_bytes=hash_bytes, bare_self=True, + from_origin=from_origin, + to_destination=to_destination, ).text() def _trace_log(self) -> RenderableType: diff --git a/tests/test_live_screens.py b/tests/test_live_screens.py index 2bafdf2..a44315b 100644 --- a/tests/test_live_screens.py +++ b/tests/test_live_screens.py @@ -389,14 +389,31 @@ def test_hop_rows_name_their_link_on_one_line_where_it_fits() -> None: """Each hop is ``n origin → destination reading meter`` — names, never a hash.""" rows = _hop_rows(_hop_rows_screen(), 100) assert len(rows) == 3 # one line a hop - assert rows[1].startswith("1 Lakeside → Mont-Royal Summit Relay") assert "-3.5 dB" in rows[1] and _BAR_FULL in rows[1] - assert rows[0].startswith("0 ★ → Lakeside") # our end is the bare star assert not any("(3d" in row or "(f2" in row for row in rows) + # Only the route's own ends are bare; every end it runs on through keeps its arrow. + assert rows[0].startswith("0 ★ → Lakeside → ") # our end is the bare star + assert rows[1].startswith("1 → Lakeside → Mont-Royal Summit Relay → ") + assert rows[2].startswith("2 → Mont-Royal Summit Relay → ★ ") # A four-column table: every reading ends in the same column. assert len({row.index(" dB") for row in rows}) == 1 +def test_hop_rows_square_only_the_routes_own_ends(powerline) -> None: # noqa: ANN001 + """As chips, the first hop opens square and the last closes square — nothing else. + + A hop is one link of the walk, so its far ends are nodes the route runs on through: + those wear the notch and the point, the marks a path line uses for "this goes on". + """ + from meshterm.ui.pathline import POWERLINE_SEP + + powerline(True) + rows = _hop_rows(_hop_rows_screen(), 50) # chips are wider: every hop folds + paths = [row[2:].rstrip() for row in rows[::2]] + assert [p.startswith(POWERLINE_SEP) for p in paths] == [False, True, True] + assert [p.endswith(POWERLINE_SEP) for p in paths] == [True, True, False] + + def test_hop_rows_fold_under_their_path_and_slide_where_it_does_not() -> None: """Too narrow for a row, every hop takes two lines; ←→ slide the paths alone. @@ -407,7 +424,7 @@ def test_hop_rows_fold_under_their_path_and_slide_where_it_does_not() -> None: screen = _hop_rows_screen() rows = _hop_rows(screen, 30) assert len(rows) == 6 # two lines a hop, all alike - assert rows[2].startswith("1 Lakeside → Mont-Royal Su") + assert rows[2].startswith("1 → Lakeside → Mont-Royal") assert rows[2].rstrip().endswith("…") # cut where it runs on assert "-3.5 dB" in rows[3] and cell_len(rows[3].rstrip()) == 30 # flush right assert "←→ scroll" in screen.footer_hint @@ -415,13 +432,15 @@ def test_hop_rows_fold_under_their_path_and_slide_where_it_does_not() -> None: screen.handle("down") # the action cursor pins the page… screen.handle("right") assert screen.cursor_line() is None # …and sliding the paths lets it go again + screen.handle("right") rows = _hop_rows(screen, 30) - assert rows[2].startswith("1 …") and rows[2].rstrip().endswith("Summit Relay") - assert rows[0].startswith("0 ★ → Lakeside") # a path in view whole never moves + assert rows[2].startswith("1 …") and rows[2].rstrip().endswith("Summit Relay →") + assert rows[0].startswith("0 ★ → Lakeside →") # a path in view whole never moves screen.handle("right") # already at the tail: clamped assert _hop_rows(screen, 30)[2] == rows[2] screen.handle("left") - assert _hop_rows(screen, 30)[2].startswith("1 Lakeside") + screen.handle("left") + assert _hop_rows(screen, 30)[2].startswith("1 → Lakeside") _hop_rows(screen, 100) # wide enough again: nothing to slide, nothing advertised assert "←→ scroll" not in screen.footer_hint