Path lines: gapped chips, both ends automatic, routes name nodes

The trace surfaces' route lanes were spending their width on hex nobody
reads there: every name carried the hash it is addressed by, and the wire
spec one lane below already spells that out verbatim, at the width it goes
on the air. The route lane, the planned-route preview and the composer's
live preview now name nodes and nothing else; an unnamed hop still reads as
its hash, since that is the only identity it has.

The composer's preview becomes that same lane rather than its own dialect —
pathline chips, hop-boundary wrapping, our two ends the bare star.

Both stars fade. Setting out from us and landing back on us are fixtures of
every such route, no more composed (or removable) than a mirrored return
leg, so they wear the automatic grey — dark slate as a chip, never the loud
you white. Finding the turn a wrapped route folds at now reads the faded
*tail* rather than the first faded hop, which the leading star had become.

Chips are separated, not interlocked: every seam draws its point with no
background, so the page shows through the taper and a route reads as a row
of blocks the way oh-my-posh spaces its segments. Two neighbours landing on
one fill can no longer fuse, without that being a special case.

Finally, two places that had the right information and didn't use it: the
composer's suggestion list fell back to bare hex for a short hop matching
several contacts (the topology won't guess, so it names nothing), and the
not-a-trail confirm printed whole 12-hex ids. Both now go through the
owning screen's own resolver and its chosen path-hash width, so a node
reads the same in the dialog as in the window behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-07-27 19:05:43 -04:00
1 parent edbd7cdcfa
commit 2c2f13ce41
8 files changed
+204 -89

No files matched your search

+54 -21
View File
@@ -1,7 +1,7 @@
"""The trace path composer: build a forced route hop by hop, guided by observed links.
A floating dialog over a live trace screen. The route under construction reads across
the top — ``us → hop → … → us`` — and beneath it sits a suggestion list: the nodes the
the top — ``★ → hop → … → ★`` — and beneath it sits a suggestion list: the nodes the
topology evidence says the path's current tail can hear, strongest observed link first
(see :meth:`~meshterm.services.topology.MeshTopology.next_hops`). Every link is
bidirectional evidence, so a path that was ever *received* through two nodes proposes
@@ -17,7 +17,7 @@ sent to the device is one walk that ends within our earshot):
yours to choose. An *Auto* action hands routing back to the device.
* **Path mode** (*Trace path* — no target at all): the whole walk is yours, hop by
hop, until the route comes back within earshot of us — only the final landing on
our own node is filled in (and dimmed). The preview opens as just ``us → us``.
our own node is filled in (and dimmed). The preview opens as just ``★ → ★``.
There is no *Auto* action, because with no destination there is nothing for the
device to route to.
@@ -46,10 +46,14 @@ Interaction, following the reorder screen's cursor-over-rows-and-actions pattern
under the preview: still walkable, but no longer a *trail*, so the trophy case
will ignore it (the no-cheat rule — see :mod:`~meshterm.services.records`).
Nodes and the route preview render through the shared path widget
(:func:`~meshterm.ui.widgets.path_text`, trace flavour) — ``Name (hash)`` with names
in their app-wide hues and our own node the white ``you`` — so a node reads the same
wherever it appears. The suggestion list scrolls in a window under the pinned route
Nodes render through the shared path widget in the trace flavour — ``Name (hash)``
with names in their app-wide hues, the hash at the session's chosen path-hash width,
and a hop the topology can't name falling back to the owning screen's own resolver
(see :meth:`PathComposerScreen._resolve_entry`), so a node reads the same here as in
the trace window this dialog floats over. The route preview is that same trace-window
route lane, live: :mod:`~meshterm.ui.pathline` chips (or arrows), wrapped at hop
boundaries, our two ends the bare ``★``, and no hash repeated after a name — the hex
is on the *Use this path* row, verbatim. The suggestion list scrolls in a window under the pinned route
preview (faint ``↑/↓ n more`` markers at its edges; PgUp/PgDn stride by a windowful),
so the route under construction never leaves the screen.
"""
@@ -68,7 +72,7 @@ from .theme import snr_style
from .tui.render import render_lines, render_to_ansi
from .tui.screen import ListWindow, Screen
from .pathline import PathLine, path_line
from .widgets import _age_seconds, _format_age, path_text
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
#: empty-spec convention). Only meaningful in target mode; a path walk has no target
@@ -146,6 +150,7 @@ class PathComposerScreen(Screen):
hops: Optional[list[str]] = None,
cursor: Optional[int] = None,
fetch_nodes: frozenset[str] = frozenset(),
resolve: NodeResolver = _identity,
) -> None:
"""Build the composer.
@@ -171,6 +176,9 @@ class PathComposerScreen(Screen):
fetch_nodes: Canonical ids whose live neighbour table can be fetched
(repeater contacts with a public key); when the path's tail is one of
them, the *fetch neighbours* row appears.
resolve: The owning screen's node resolver — the fallback that names a hop
the topology can't (see :meth:`_resolve_entry`), so the composer reads
the same names the screen that opened it does.
"""
super().__init__()
self.title = f"Compose path — {target_label}" if target_label else "Compose path"
@@ -180,6 +188,7 @@ class PathComposerScreen(Screen):
self._device_label = device_label
self._device_hash = (device_hash or "").lower().removeprefix("0x")
self._topology = topology
self._resolve = resolve
self._width_bytes = width_bytes
self._hops: list[str] = list(hops or [])
#: The insertion cursor: which joining arrow of the editable leg it sits on.
@@ -314,10 +323,23 @@ class PathComposerScreen(Screen):
return node
def _resolve_entry(self, entry: str) -> str:
"""Resolve a preview entry to its display name (the target by its label)."""
"""Resolve an entry to its display name — the target's label, else a node's.
Two resolvers, in order, because they answer different questions. The topology
names a node it has *identified*: a hop hash that prefix-matches exactly one
contact is that contact, so its canonical id carries the name. But a short hash
— a 1-byte hop, the common case at the protocol's default width — often matches
several contacts, and rather than guess, the topology keeps it as itself and
knows no name for it. The owning screen's resolver
(:func:`~meshterm.services.trace_runner.make_node_resolver`) is the same
first-match lookup the trace screens name their walked hops by, so falling back
to it means a node the trace window shows as *YUL-Poly* is *YUL-Poly* here too,
instead of a bare ``3d`` the reader has to decode. Nothing is invented: an
unmatched hash comes back unchanged and renders as a hash.
"""
if self._target_id is not None and entry == (self._target_hash or self._target_id):
return self._target_label or entry
return self._topology.display_name(entry) or entry
return self._topology.display_name(entry) or self._resolve(entry) or entry
def _node_text(self, node: str, *, dim: bool = False) -> Text:
"""One route node through THE path widget, the trace presentation.
@@ -343,15 +365,26 @@ class PathComposerScreen(Screen):
def _route_preview(self) -> "PathLine":
"""The route under construction, endpoints filled in automatically.
THE path widget in the trace flavour: target mode shows the composed
outbound in full colour, then the pinned target and the mirrored return
resolved and dimmed right alongside it (``dim_from``) — the dimming reads
as "this half isn't yours to compose". Path mode shows every composed hop
in full colour (they are all yours) between our own node at both ends,
with only the final landing back on us dimmed. The caller renders the line
with the insertion cursor riding the arrow it stands on (``cursor_arrow``),
a reverse-video ``→`` — which pins the preview to plain arrows: an editor
needs the seams its cursor sits in.
THE path widget, drawn exactly as the trace screen that opened this dialog
draws its route lane — the composer is a live preview of that lane, so the two
must be the same picture. Target mode shows the composed outbound in full
colour, then the pinned target and the mirrored return resolved and dimmed
right alongside it (``dim_from``) — the dimming reads as "this half isn't yours
to compose". Path mode shows every composed hop in full colour (they are all
yours) between our own node at both ends, with only the final landing back on
us dimmed.
Both our ends go bare (``bare_self``): a composed walk always leaves us and
comes home to us, so the ``★`` says it in one cell and leaves the rest of the
dialog's width to the hops being chosen. Named hops show no hash either — the
wire spec on the *Use this path* row is the hex, verbatim and at the width it
goes on the air, so repeating it after every name would only crowd the route
this dialog exists to shape. An unnamed hop still reads as its hash, truncated
to the session's chosen path-hash width.
The caller renders the line with the insertion cursor riding the arrow it
stands on (``cursor_arrow``), a reverse-video ``→`` — which pins the preview to
plain arrows: an editor needs the seams its cursor sits in.
"""
entries: list[Optional[str]] = [None]
entries.extend(self._path_entry(hop) for hop in self._hops)
@@ -362,9 +395,10 @@ class PathComposerScreen(Screen):
return path_line(
entries, self._resolve_entry,
prefix_bytes=self._width_bytes, self_name=self._device_label,
show_hash=True, hash_bytes=self._width_bytes,
hash_bytes=self._width_bytes,
device_hash=self._device_hash or None,
dim_from=(2 if self._mirrored else 1) + len(self._hops),
bare_self=True,
)
def _suggestion_text(self, suggestion) -> Text: # noqa: ANN001
@@ -393,9 +427,8 @@ class PathComposerScreen(Screen):
if kind == "hop":
return self._suggestion_text(payload)
if kind == "fetch":
name = self._topology.display_name(str(payload)) or str(payload)[:12]
text = Text("⇣ Fetch neighbours from ", style="")
text.append(name, style="brand")
text.append_text(self._node_text(str(payload))) # named like every other row
text.append(" (asks the repeater over the mesh)", style="muted")
return text
if payload == _USE:
+52 -28
View File
@@ -8,13 +8,13 @@ caller), a :class:`PathLine` separates *what the hops are* from *how the line is
drawn*, so every surface the survey found can eventually route through it:
* **Two separator styles.** ``plain`` joins hops with muted ``→`` arrows — today's
look, unchanged. ``powerline`` renders each hop as a colour-filled chip joined by
the solid-triangle separator U+E0B0 (foreground = previous chip's fill, background
= next's — the interlocking oh-my-posh look, opening to a page-coloured gap where
two neighbours share a fill and the interlock would fuse them into one block), the
chip fill being the node's hash-derived hue
look, unchanged. ``powerline`` renders each hop as a colour-filled chip separated by
the solid-triangle U+E0B0 drawn on the page — the gapped oh-my-posh look: each chip
tapers out to a point and the page shows through the wedge behind it, so a route
reads as a row of distinct blocks rather than one fused ribbon. The
chip fill is the node's hash-derived hue
(:func:`~meshterm.ui.theme.node_style`), our own node the pure ``you`` white, a
keyless hop grey. ``auto`` (the default) picks powerline exactly
faded hop dark slate, a keyless hop grey. ``auto`` (the default) picks powerline exactly
when the terminal can draw it (:func:`~meshterm.ui.termfont.powerline_enabled` —
a recommended font, a glyph-capable renderer, or the user's override) and falls
back to arrows everywhere else, so no terminal ever sees tofu.
@@ -438,17 +438,24 @@ class PathLine:
def _turn_seam(self) -> Optional[int]:
"""The hop index a fold would read best at — where the route turns — or ``None``.
Two things mark a turn. A dimmed tail is one: the composed outbound leg ends
and the mirrored return begins (see :attr:`PathHop.dim`). A walked boomerang is
Two things mark a turn. A dimmed *tail* is one: the composed outbound leg ends
and the mirrored return begins (see :attr:`PathHop.dim`). It is read as the run
of faded hops that reaches the end of the line, not merely the first faded hop —
a route's opening ``★`` fades too (setting out from us is nobody's choice
either), and that lone fixture at the head marks nothing. A walked boomerang is
the other — nothing is dimmed once a trace has actually answered, but the hop
sequence is its own mirror, so its middle *is* that same turn; folding the plan
and the walk that answered it in the same place keeps the two readings of one
route looking like one route. Either way the fold needs real legs on both
sides — at least two hops each — so a path whose *only* dim hop is the
sides — at least two hops each — so a path whose only faded tail is the
automatic landing back on us never widows it onto a line of its own; that lone
faded tail marks no turn, and the walk's own mirror still gets to name one.
hop marks no turn, and the walk's own mirror still gets to name one.
"""
seam = next((i for i, hop in enumerate(self._hops) if hop.dim), None)
seam: Optional[int] = len(self._hops)
while seam and self._hops[seam - 1].dim:
seam -= 1
if seam == len(self._hops): # nothing faded at the tail
seam = None
if seam is None or len(self._hops) - seam < 2:
marks = [(hop.label, hop.annotation) for hop in self._hops]
if len(marks) % 2 and marks == marks[::-1]:
@@ -523,7 +530,7 @@ class PathLine:
def _render_chips(
self, hops: list[PathHop], *, carry_in: bool = False, carry_on: bool = False
) -> Text:
"""Powerline chips: each hop filled with its hue, seams interlocked.
"""Powerline chips: each hop filled with its hue, a page-wide 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 (squared off where the font has no
@@ -558,27 +565,40 @@ class PathLine:
@staticmethod
def _seam(before: str, after: str) -> str:
"""One seam's style: interlocked into the next fill, or open onto the page.
"""One seam's style: the previous chip's point, open onto the page.
Two ways to draw the point. Interlocked — the previous fill *on* the next
one — is the tighter look and the default. But two neighbours can land on the
same fill (a hue collision, a run of dimmed hops, two keyless greys), and then
an interlock is invisible: one fused block where the route has two nodes.
Those open onto the page instead, the taper leaving the sliver of background
that says where one chip ends and the next begins.
The point is drawn in the previous fill with *no* background of its own, so the
page shows through the wedge it tapers out of — the small gap oh-my-posh leaves
between segments. The alternative (interlocking the point into the next chip's
fill) packs the row tighter, but it fuses the route into one continuous ribbon,
and two neighbours that happen to land on the same fill — a hue collision, a run
of faded hops, two keyless greys — then read as a single block where the route
has two nodes. One gap, every seam: a chip is always a chip.
Args:
before: The fill the seam tapers out of (the previous chip's).
after: The fill the next chip opens on — no longer part of the seam, kept
so callers still read as "this seam, between these two fills".
"""
return before if before == after else f"{before} on {after}"
return before
def _chip_fill(self, hop: PathHop) -> str:
"""A chip's fill colour: override, white you, dim slate, hue, keyless grey."""
"""A chip's fill colour: override, dim slate, white you, hue, keyless grey.
The fade outranks identity — including our own. A dimmed hop is one nobody
composed (an automatic landing back home, a mirrored return leg), and the white
``you`` chip is the loudest thing on the line: our automatic end must recede
with the rest of the automatic half, not shout over the hops that *are* news.
Plain mode says the same thing by fading the name.
"""
if hop.style:
resolved = _style_hex(hop.style)
if resolved:
return resolved
if hop.you:
return _YOU_BG
if hop.dim:
return _DIM_BG
if hop.you:
return _YOU_BG
if hop.key:
return _style_hex(node_style(hop.key)) or _KEYLESS_BG
return _KEYLESS_BG
@@ -656,9 +676,10 @@ def path_line(
bare_self: Draw our own device (a ``None`` hop) as the bare
:data:`SELF_GLYPH`. For a surface whose route *always* begins and ends on
us, the name and hash on both ends say nothing the reader doesn't know,
and cost the cells the hops in between need. A ``★`` in the *last*
position also fades: coming home closes every route automatically, so
that end is no more composed than a resolved return leg is.
and cost the cells the hops in between need. Both stars also fade: setting
out from us and coming home to us are fixtures of every such route, no more
composed — or removable — than a mirrored return leg is, so they wear the
same automatic grey.
mode: The :class:`PathLine` mode (``"auto"``/``"powerline"``/``"plain"``).
Returns:
@@ -670,8 +691,11 @@ def path_line(
dim = dim_from is not None and i >= dim_from
if hop is None:
if bare_self:
homecoming = i == len(shown) - 1 # nobody composed the way back to us
built.append(PathHop(SELF_GLYPH, you=True, dim=dim or homecoming))
# Both our ends are fixed: a route leaves us and comes home to us, and
# neither end is anybody's to compose or remove. So the stars fade like
# every other automatic hop — the colour on the line belongs to the
# nodes actually being chosen.
built.append(PathHop(SELF_GLYPH, you=True, dim=True))
continue
note = _shorten(device_hash, hash_bytes) if (show_hash and device_hash) else None
built.append(
+15 -6
View File
@@ -857,7 +857,9 @@ class TraceScreen(Screen):
Both ends go bare (``bare_self``): every walk on this screen leaves us and comes
back to us, so naming ourselves twice would only push the hops that *are* news
out of the lane.
out of the lane. Named hops go bare of their hash too: this lane answers *which
nodes*, and the hex it would repeat after every name is already spelled out
verbatim, at the width it goes on the air, in the ``path`` lane below.
Args:
current: The newest successful trace of this session, if any.
@@ -871,7 +873,7 @@ class TraceScreen(Screen):
if current is not None:
route = _route_path(
current, self._device_label, self._resolve, self._device_hash,
bare_self=True,
bare_self=True, show_hash=False,
)
return route.wrapped(width, indent=indent)
planned = self._planned_route()
@@ -883,7 +885,7 @@ class TraceScreen(Screen):
if self._previous is not None:
route = _route_path(
self._previous, self._device_label, self._resolve, self._device_hash,
bare_self=True,
bare_self=True, show_hash=False,
)
lines = route.wrapped(width, indent=indent)
stamp = self._previous.timestamp.astimezone().strftime("%b %d %H:%M")
@@ -930,7 +932,12 @@ class TraceScreen(Screen):
try:
prompt = Text("This walk crosses ", style="warn")
prompt.append_text(
_link_text(edge[0], edge[1], self._device_label, self._resolve)
# The hashes read at the width the screen addresses hops by, like
# every other node this session shows.
_link_text(
edge[0], edge[1], self._device_label, self._resolve,
self._width_bytes(), self._device_hash,
)
)
prompt.append(" twice in the same direction, ", style="warn")
prompt.append(
@@ -964,7 +971,8 @@ class TraceScreen(Screen):
seam (the turn). A path walk's spec is the whole route (hand-composed, or the
last stored walk), so every hop renders in full colour and only the automatic
landing back on us stays faint. Our own endpoints go bare, as in
:meth:`_route_value` — the arrow alone.
:meth:`_route_value` — the arrow alone — and so does every hop's hash: the lane
names nodes, and the spec below it is the hex, verbatim.
"""
spec, _ = self._effective_spec()
tokens = [h.strip() for h in spec.split(",") if h.strip()]
@@ -980,7 +988,7 @@ class TraceScreen(Screen):
self._resolve,
prefix_bytes=8, # spec tokens are the addressed slices: light them whole
self_name=self._device_label,
show_hash=True,
show_hash=False,
dim_from=1 + len(outbound),
bare_self=True,
)
@@ -1632,6 +1640,7 @@ async def _open_session(
hops=seed,
cursor=seed_cursor,
fetch_nodes=frozenset(fetchable),
resolve=resolve, # name a hop the topology left ambiguous, as we do
)
result = await session.run_screen(screen)
if result is CANCEL:
+1
View File
@@ -658,6 +658,7 @@ async def open_tx_optimize(
target_hash=admin_hash,
target_label=admin_node.name,
hops=[topo.canonical(h) or h for h in screen.route_hops],
resolve=resolve, # name a hop the topology left ambiguous, as we do
)
result = await session.run_screen(composer)
if result is CANCEL or not isinstance(result, str):
+7 -1
View File
@@ -562,6 +562,7 @@ def _route_path(
device_hash: Optional[str] = None,
*,
bare_self: bool = False,
show_hash: bool = True,
) -> PathLine:
"""A trace's walked route as THE path widget's line object.
@@ -578,6 +579,11 @@ def _route_path(
bare_self: Draw both ``us`` endpoints as their bare arrow — for a surface (the
trace screens' route lane) where a walk starting and ending on us is the
premise, not news.
show_hash: Annotate each *named* hop with the hash it was addressed by. On for
the scripted table output, where the hash is half the answer; off for the
live screens, whose route lane reads as the sequence of nodes and leaves
the hex to the wire-spec lane below it. Unnamed hops always show their
hash — it is the only identity they have.
Returns:
The route's :class:`~meshterm.ui.pathline.PathLine` (hopless — reading
@@ -591,7 +597,7 @@ def _route_path(
hops = [None if not node or node == device_label else node for node in nodes]
return path_line(
hops, resolve, prefix_bytes=hash_bytes or 8, self_name=device_label,
show_hash=True, hash_bytes=hash_bytes, device_hash=device_hash,
show_hash=show_hash, hash_bytes=hash_bytes, device_hash=device_hash,
bare_self=bare_self,
)
+5 -4
View File
@@ -669,8 +669,8 @@ def test_route_lane_wraps_at_hop_boundaries_under_its_own_column() -> None:
"""A route too wide for the lane breaks between hops, never inside one.
Every continuation hangs under the value column (never back at column zero), the
line it continues from ends on the ``→`` cue, and no name is ever parted from the
hash it is addressed by.
line it continues from ends on the ``→`` cue, and every name survives whole — the
lane names nodes and leaves the hex to the ``path`` lane below it.
"""
names = {"3d": "YUL-Cartierville", "f2": "Mile-End-Rooftop", "27": "Beaubien-Sud"}
screen, _ = _trace_screen(mode="path")
@@ -681,8 +681,9 @@ def test_route_lane_wraps_at_hop_boundaries_under_its_own_column() -> None:
assert all(len(line) <= 60 for line in lines)
assert all(line.startswith(" " * 7) for line in lines[1:]) # hanging, not column 0
assert all(line.rstrip().endswith("→") for line in lines[:-1])
for hop, name in names.items(): # every name still carries its own hash, whole
assert any(f"{name} ({hop})" in line for line in lines)
for hop, name in names.items(): # names whole, and no hash trailing any of them
assert any(name in line for line in lines)
assert all(f"({hop})" not in line for line in lines)
def test_path_lane_breaks_the_wire_spec_after_a_comma() -> None:
+30 -17
View File
@@ -86,9 +86,10 @@ def test_empty_path_reads_as_the_callers_word() -> None:
assert PathLine([], mode="plain").wrapped(40)[0].plain == "direct"
def test_chips_interlock_fills_through_the_separator() -> None:
"""Each seam's triangle wears the previous chip's fill as foreground on the next
chip's fill as background; the last points into the page (foreground only)."""
def test_chips_are_separated_by_a_gap_not_an_interlock() -> None:
"""Every seam's triangle carries only a foreground — the previous chip's fill — so
the page shows through the wedge and the chips read as separate blocks (the
oh-my-posh gap), interior seams and the closing edge alike."""
alice_fill = node_style("aa").split()[-1]
line = PathLine(
[PathHop("Alice", key="aa"), PathHop("you", you=True)], mode="powerline"
@@ -97,7 +98,7 @@ def test_chips_interlock_fills_through_the_separator() -> None:
assert text.plain == f" Alice {POWERLINE_SEP} you {POWERLINE_SEP}"
seam_styles = [str(s.style) for s in text.spans
if text.plain[s.start:s.end] == POWERLINE_SEP]
assert seam_styles == [f"{alice_fill} on #ffffff", "#ffffff"]
assert seam_styles == [alice_fill, "#ffffff"] # no "on": nothing behind the point
def test_chips_keep_the_same_words_and_honour_style_overrides() -> None:
@@ -340,19 +341,19 @@ 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_same_fill_neighbours_get_the_open_seam() -> None:
"""Two chips of one colour — a hue collision, a run of dimmed hops — would fuse
into a single block behind an interlocked seam, so theirs opens onto the page and
leaves a visible gap. Chips of different fills keep interlocking."""
same = PathLine([PathHop("A", key="aa"), PathHop("B", key="aa")], mode="powerline")
def test_every_seam_opens_onto_the_page_whatever_the_fills() -> None:
"""One gap rule, no exceptions: a seam never takes a background, so two chips of
one colour — a hue collision, a run of dimmed hops — can't fuse into a single block,
and two chips of different colours are still visibly two chips."""
hue = _style_hex(node_style("aa"))
same = PathLine([PathHop("A", key="aa"), PathHop("B", key="aa")], mode="powerline")
seam = next(s for s in same.text().spans if same.text().plain[s.start] == POWERLINE_SEP)
assert str(seam.style) == hue # foreground only: the page shows through the taper
apart = PathLine([PathHop("A", key="aa"), PathHop("B", key="77")], mode="powerline")
text = apart.text()
seams = [str(s.style) for s in text.spans if text.plain[s.start] == POWERLINE_SEP]
assert seams[0] == f"{hue} on {_style_hex(node_style('77'))}"
assert seams[0] == hue # the fill it tapers out of, and nothing behind it
dimmed = PathLine([PathHop("A", dim=True), PathHop("B", dim=True)], mode="powerline")
text = dimmed.text()
@@ -360,18 +361,30 @@ def test_same_fill_neighbours_get_the_open_seam() -> None:
assert " on " not in gaps[0] # a dimmed return leg reads as hops, not one bar
def test_bare_self_stands_us_on_a_star_and_fades_the_way_home() -> None:
"""``bare_self`` replaces our name and hash with the app-wide ★ at both ends. The
one we set out from stays the you white; the one we land back on fades, because
coming home closes every route automatically — nobody composed it."""
def test_bare_self_stands_us_on_a_star_and_fades_both_ends() -> None:
"""``bare_self`` replaces our name and hash with the app-wide ★ at both ends, and
both fade: setting out from us and landing back on us are fixtures of the route,
not choices, so they wear the same automatic grey as a mirrored return leg —
and, in chips, the same dark slate rather than the loud you white."""
hops = [None, *(f"{i:02x}aa" for i in range(6)), None]
text = path_line(hops, prefix_bytes=2, self_name="Me", show_hash=True,
device_hash="a1b2", bare_self=True, mode="plain").text()
line = path_line(hops, prefix_bytes=2, self_name="Me", show_hash=True,
device_hash="a1b2", bare_self=True, mode="plain")
text = line.text()
body = " → ".join(f"{i:02x}aa" for i in range(6))
assert text.plain == f"{SELF_GLYPH} → {body} → {SELF_GLYPH}"
assert "Me" not in text.plain and "a1b2" not in text.plain
stars = [s for s in text.spans if text.plain[s.start : s.end] == SELF_GLYPH]
assert [str(s.style) for s in stars] == ["you", "faint"] # out in white, home grey
assert [str(s.style) for s in stars] == ["faint", "faint"]
chips = PathLine(line.hops, mode="powerline").text()
star_fills = [str(s.style) for s in chips.spans
if chips.plain[s.start : s.end] == SELF_GLYPH]
assert all("#ffffff" not in fill for fill in star_fills) # never the you white
wrapped = path_line(hops, prefix_bytes=2, self_name="Me", bare_self=True,
mode="powerline").wrapped(28, indent=2)
assert len(wrapped) > 1
assert all(text.plain[2 + WRAP_OFFSET] == POWERLINE_SEP for text in wrapped[1:])
assert all(text.cell_len <= 28 for text in wrapped)
lines = path_line(hops, prefix_bytes=2, self_name="Me", bare_self=True,
mode="powerline").wrapped(28, indent=2)
+40 -12
View File
@@ -23,6 +23,7 @@ from meshterm.persistence.repository import (
from meshterm.services.path_probe import ProbeCandidate, ProbeOutcome, probe_paths
from meshterm.services.topology import build_topology, collapse_width, render_custom_spec
from meshterm.ui.path_composer import AUTO_SPEC, FetchNeighbours, PathComposerScreen
from meshterm.ui.pathline import SELF_GLYPH
from meshterm.ui.tui.screen import CANCEL
US = "aaaaaaaaaaaa"
@@ -392,7 +393,9 @@ async def test_probe_paths_defaults_to_one_trace_per_candidate() -> None:
# --- path composer ---------------------------------------------------------------------
def _composer(topo, hops=None, fetch_nodes=frozenset(), target=True, cursor=None): # noqa: ANN001
def _composer( # noqa: ANN001
topo, hops=None, fetch_nodes=frozenset(), target=True, cursor=None, resolve=None
):
"""A composer over ``topo`` — pinned on Far (target mode) or target-less."""
pinned = (
dict(
@@ -411,6 +414,7 @@ def _composer(topo, hops=None, fetch_nodes=frozenset(), target=True, cursor=None
hops=list(hops or []),
cursor=cursor,
fetch_nodes=fetch_nodes,
**({"resolve": resolve} if resolve is not None else {}),
**pinned,
)
screen.note_viewport(30) # the frame records the dialog budget before each paint
@@ -443,7 +447,7 @@ def test_composer_windows_rows_under_the_pinned_route_preview() -> None:
screen = _composer(_topo(trace_paths=walks, contacts=[FAR]))
screen.note_viewport(9) # a short dialog budget: preview + heading + a few rows
body = re.sub(r"\x1b\[[0-9;]*m", "", "\n".join(screen.render_body(90)))
assert "Us (aa)" in body # the route preview is pinned, never scrolled out
assert SELF_GLYPH in body # the route preview is pinned, never scrolled out
assert "↓" in body and "more" in body # hidden rows are counted at the edge
screen.handle("end") # cursor to the last action row — the window follows
body = re.sub(r"\x1b\[[0-9;]*m", "", "\n".join(screen.render_body(90)))
@@ -604,24 +608,48 @@ async def test_composer_commits_spec_auto_and_cancel() -> None:
assert cancelled.future.result() is CANCEL
def test_composer_path_mode_opens_us_to_us_without_auto() -> None:
"""Target-less, the preview is just ``us → us`` and there is no Auto action.
def test_composer_path_mode_opens_star_to_star_without_auto() -> None:
"""Target-less, the preview is just ``★ → ★`` and there is no Auto action.
Only the final landing back on us dims — the hops (and the opening endpoint)
are all the user's to compose, and with no destination there is nothing for
the device to route to.
Both ends are our own node, bare and faded: a walk always leaves us and comes home
to us, and neither end is the user's to compose or remove — the automatic grey says
so. With no destination there is nothing for the device to route to either.
"""
screen = _composer(_topo(), target=False)
assert screen.title == "Compose path" # no target to name
preview = screen._route_preview().text(cursor_arrow=screen._cursor)
assert preview.plain == "Us (aa) → Us (aa)" # endpoints carry our hash
faint = sum(
span.end - span.start for span in preview.spans if "faint" in str(span.style)
)
assert 0 < faint < len(preview.plain) # some dimming (the landing), never all
assert preview.plain == f"{SELF_GLYPH} → {SELF_GLYPH}" # our name and hash go unsaid
stars = [
str(span.style) for span in preview.spans
if preview.plain[span.start : span.end] == SELF_GLYPH
]
assert stars == ["faint", "faint"] # read-only, like every auto-managed chip
assert "Auto" not in _rows_plain(screen)
def test_composer_names_an_ambiguous_hop_through_the_owning_screens_resolver() -> None:
"""A short hop the topology won't name still reads as a name, with its hash beside it.
A 1-byte hop that prefix-matches two contacts is ambiguous, so the topology refuses
to guess and keeps it as the bare hash — which left the suggestion list proposing
opaque hex for nodes the trace window behind it was happily naming. The owning
screen's resolver is the fallback, so both surfaces say the same thing.
"""
twin = Contact(name="Twin", public_key="3d99" + "0" * 60) # makes a bare "3d" ambiguous
topo = _topo(trace_paths=[_traced(("3d", 12.0), (None, 12.0))],
contacts=[REPEATER, twin])
assert topo.display_name("3d") is None # two contacts match: the graph won't pick
import re
blind = _composer(topo, target=False)
assert "Hub" not in _rows_plain(blind) # without a fallback: bare hex, as before
named = _composer(topo, target=False, resolve=lambda h: "Hub" if h == "3d" else h)
body = re.sub(r"\x1b\[[0-9;]*m", "", _rows_plain(named))
assert "Hub (3d)" in body # the name, and the hash it is addressed by beside it
async def test_composer_path_mode_suggests_through_anything_and_commits_verbatim() -> None:
"""A path walk routes *through* any node and commits exactly the composed hops."""
import asyncio