Path lines: the cursor is a hop; message paths read as names

The composer's insertion point was a reverse-video arrow living between two
hops, which forced the whole preview back to plain arrows — chips fuse the seam
it needed to sit in — and made a wrapped route carry the cursor by hand, riding
a line's trailing cue when the break landed on it.

An insertion point is a position in the route, so it is now a hop of its own: a
`+` slot in the brand accent (the same colour the row cursor below it wears),
spliced in by `path_line(cursor=…)`. It measures, wraps, and colours like every
other hop, so the preview keeps whichever mode the terminal earned and can never
strand its cursor on a fold. `cursor_arrow` goes with it, out of both `PathLine`
and `path_text`.

Message paths: the arrival rows draw through the path line too — chips in each
node's hue, names alone, no hash repeated after one. The hash bytes are the
graph's job one row up, and the shared node hue ties the two together without
spelling the hex twice. The graph loses the blank line above it; its canvas
already opens with air over the topmost lane.

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 21:16:47 -04:00
1 parent fc9a459eda
commit c537fa971e
8 files changed
+161 -150

No files matched your search

+19 -14
View File
@@ -14,10 +14,12 @@ detail read together:
through it. A node-type key sits under the graph. The row list carries the full names,
so the graph's labels stay two cells wide and a many-path graph stays readable.
* the **arrival list** beneath is one row per logged copy — time, reception SNR, and
the relay chain through the shared compact path widget, each named hop annotated
with the hash byte it is addressed by (``YUL-Poly (3d)``, the trace presentation);
an unknown relay stands in its own hash at the device's path-hash width, muted grey
and annotated with that byte (``e839f2 (e8)``).
the relay chain 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 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.
↑↓ move the selection (the graph's highlight follows); a row 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.
@@ -35,6 +37,7 @@ from rich.text import Text
from ..core.models import ChatMessage
from ..services.message_paths import Arrival
from .pathgraph import PathLayer, render_path_graph
from .pathline import path_line
from .theme import snr_style
from .tui.render import crop_cells, render_to_ansi
from .tui.screen import Screen
@@ -43,7 +46,6 @@ from .widgets import (
NodeResolver,
TypeOf,
node_type_legend,
path_text,
route_graph_style,
)
@@ -183,7 +185,8 @@ class MessagePathsScreen(Screen):
self._scroll_total = len(lines)
return lines
lines.append("")
# No blank line above the graph: its canvas already opens with air over the
# topmost lane, so a spacer here would read as two rows of margin.
lines.extend(self._graph_lines(width))
caption = Text("origin → you · white = selected path · labels = hash byte",
style="faint")
@@ -206,11 +209,13 @@ class MessagePathsScreen(Screen):
def _row_text(self, arrival: Arrival) -> Text:
"""One arrival, unabridged: time, reception SNR, and its relay path.
Hops carry the trace presentation at the graph's grain: each named hop is
annotated with the hash byte it is addressed by (``YUL-Poly (3d)``); an unnamed
hop, having no name to show, stands in its own hash at the device's path-hash
width — muted grey, since colour is the "this is a name" signal — annotated with
that same byte (``e839f2 (e8)``), so the rows and the graph's labels cross-reference.
The relay chain is THE path line, so a route here is drawn exactly as a route
anywhere else — chips in each node's own hue where the terminal can draw them,
arrows where it can't. Hops read as names, nothing else: an unnamed hop, having
no name to show, stands in its own hash at the device's path-hash width (muted
grey — colour is the "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.
"""
row = Text()
row.append(arrival.when.astimezone().strftime("%H:%M:%S"), style="muted")
@@ -223,11 +228,11 @@ class MessagePathsScreen(Screen):
if arrival.hops:
row.append("via ", style="muted")
row.append_text(
path_text(
path_line(
arrival.hops, self._resolve,
prefix_bytes=self._prefix_bytes, self_name=self._self_name,
empty="direct", show_hash=True, hash_bytes=1, hash_as_name=True,
)
empty="direct", hash_as_name=True,
).text()
)
if arrival.resend:
row.append(f" (resend #{arrival.resend})", style="muted")
+23 -18
View File
@@ -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 ``★ → ★``.
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.
@@ -25,12 +25,12 @@ Interaction, following the reorder screen's cursor-over-rows-and-actions pattern
* ↑/↓ move over suggestions and the action rows; Enter on a suggestion inserts it
at the route's insertion cursor.
* ←/→ slide that insertion cursor along the editable leg's joining arrows — drawn
as a reverse-video ``→`` — so a hop can be spliced in (to the cursor's right) or
removed (⌫, to its left) anywhere in the route, not just at the end. It opens on
the last arrow, where inserting is appending — the classic flow unchanged. The
suggestion list follows: it always proposes next hops from the node left of the
cursor.
* ←/→ slide that insertion cursor along the editable leg — it rides *in* the route as
a hop of its own, the brand-accent ``+`` slot the next chosen node drops into — so a
hop can be spliced in (at the slot) or removed (⌫, to its left) anywhere in the
route, not just at the end. It opens at the end of the composed leg, where inserting
is appending — the classic flow unchanged. The suggestion list follows: it always
proposes next hops from the node left of the cursor.
* Typing filters the suggestions by name or hash — and when the typed text is itself
even-length hex, an *add custom hop* row appears, so a node we have never observed
(or a bare hash from another tool) can be forced into the route.
@@ -493,9 +493,13 @@ class PathComposerScreen(Screen):
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.
The insertion cursor is *in* the route, not between two of its hops: the
``+`` slot (:data:`~meshterm.ui.pathline.CURSOR_GLYPH`) sits where the next
chosen node will, in the brand accent the row cursor below it wears. Drawing an
insertion point as a position rather than as a gap is what lets the preview stay
the picture the trace window paints — chips and all, rather than falling back to
arrows for the sake of a seam to sit in — and lets a wrapped route carry the
cursor like any other hop instead of stranding it on a line break.
"""
entries: list[Optional[str]] = [None]
entries.extend(self._path_entry(hop) for hop in self._hops)
@@ -510,6 +514,9 @@ class PathComposerScreen(Screen):
device_hash=self._device_hash or None,
dim_from=(2 if self._mirrored else 1) + len(self._hops),
bare_self=True,
# The entries open on our own ★, so arrow index k — the slot after display
# node k — is rendered-hop index k + 1.
cursor=self._cursor + 1,
)
def _suggestion_text(self, suggestion) -> Text: # noqa: ANN001
@@ -556,9 +563,9 @@ class PathComposerScreen(Screen):
def dialog_width(self) -> int:
"""Natural outer width hugging the widest row (compositor still caps it)."""
widths = [cell_len(self.title), cell_len(self.footer_hint)]
# Measured with the cursor so the width matches the body's plain render (a
# cursor arrow and a plain arrow are both three cells — never a mismatch).
widths.append(self._route_preview().text(cursor_arrow=self._cursor).cell_len)
# The preview carries its own cursor slot, so the measured line is the drawn
# line — same hops, same mode, same width.
widths.append(self._route_preview().text().cell_len)
if first_repeated_edge(self._walk_nodes()) is not None:
widths.append(cell_len(_NOT_A_TRAIL))
for kind, payload in self._rows():
@@ -576,13 +583,11 @@ class PathComposerScreen(Screen):
self._index = max(0, min(self._index, len(rows) - 1))
# The preview breaks at hop boundaries under a 2-column hanging indent (never
# mid-name), the insertion cursor always on its seam — even when the seam is a
# line break, where it rides that line's trailing arrow.
# mid-name, never mid-chip); the insertion slot is one of those hops, so it
# lands on a line like the rest and is always visible.
lines = [
render_to_ansi(line, width, no_wrap=True)
for line in self._route_preview().wrapped(
width, indent=2, cursor_arrow=self._cursor
)
for line in self._route_preview().wrapped(width, indent=2)
]
if first_repeated_edge(self._walk_nodes()) is not None:
lines.extend(render_lines(Text(_NOT_A_TRAIL, style="warn"), width))
+52 -51
View File
@@ -40,9 +40,11 @@ drawn*, so every surface the survey found can eventually route through it:
that *are* news.
Existing call sites still render through ``path_text``; migrating them here is a
separate, per-surface pass. The composer's insertion cursor is the one deliberate
mode exception: a render asked for ``cursor_arrow`` always draws plain arrows, since
an insertion point lives *between* hops and chips fuse that seam shut.
separate, per-surface pass. The composer's insertion cursor rides along as a hop in
its own right (``cursor=True``, drawn as the :data:`CURSOR_GLYPH` slot): an insertion
point *is* a position in the route, not a gap between two of them, so it measures,
wraps and colours like every other hop — the preview keeps whichever mode the terminal
earned, and a cursor can never be stranded on a line break.
"""
from __future__ import annotations
@@ -75,6 +77,12 @@ POWERLINE_ROUND_CLOSE = ""
#: (``bare_self``): the app-wide ``★`` — the same mark the map plants on us.
SELF_GLYPH = "★"
#: The composer's insertion cursor, standing in the route as a hop of its own: the
#: empty slot the next chosen hop drops into. A ``+`` because that is exactly what
#: Enter does there — and because no node is ever named one, so the slot can't be
#: misread as a hop that is already in the route.
CURSOR_GLYPH = "+"
#: The plain-mode joining arrow, exactly as ``path_text`` draws it today.
_ARROW = " → "
@@ -123,6 +131,10 @@ class PathHop:
style: Explicit colour override — a hex style (``"#rrggbb"``, attributes
allowed) or theme name — for context colourings that outrank identity.
Plain mode uses it as the label style; powerline as the chip fill.
cursor: This hop is an editor's insertion slot rather than a node — the
:data:`CURSOR_GLYPH` in the app's brand accent, the same colour the list
cursor (``❯``) below it wears, so the two halves of one gesture (the row
you pick, the slot it lands in) read as one thing.
"""
label: str
@@ -132,6 +144,7 @@ class PathHop:
lit_bytes: int = 0
dim: bool = False
style: Optional[str] = None
cursor: bool = False
def _style_hex(style: str) -> Optional[str]:
@@ -186,21 +199,15 @@ class PathLine:
# --- the three shapes ---------------------------------------------------------
def text(self, *, cursor_arrow: Optional[int] = None) -> Text:
def text(self) -> Text:
"""The full one-line rendering; overflow is the caller's call, per surface.
Args:
cursor_arrow: Draw this joining gap (0-based, between hops *j* and
*j+1*) as the composer's reverse-video insertion cursor. Forces
plain mode for the render — chips fuse the seam an insertion
point needs to sit in.
Returns:
A one-line :class:`Text` (the ``empty`` note when there are no hops).
"""
if not self._hops:
return Text(self._empty, style="muted")
return self._render(self._hops, cursor_arrow=cursor_arrow)
return self._render(self._hops)
def ellipsized(self, width: int) -> Text:
"""The line fitted to ``width`` by eliding *middle* hops behind ``⋯``.
@@ -231,9 +238,7 @@ class PathLine:
last.truncate(width, overflow="ellipsis")
return last
def wrapped(
self, width: int, *, indent: int = 0, cursor_arrow: Optional[int] = None
) -> list[Text]:
def wrapped(self, width: int, *, indent: int = 0) -> list[Text]:
"""The line broken at hop boundaries, hanging under ``indent`` columns.
The hanging-indent convention: the caller lays its label lane on the first
@@ -258,9 +263,6 @@ class PathLine:
Args:
width: The full line budget, indent included.
indent: The hanging-indent column the continuations align under.
cursor_arrow: The composer's insertion cursor, as in :meth:`text` (0-based
seam index). Forces plain arrows; a cursor sitting exactly on a line
break lands on that line's trailing cue, so it is always visible.
Returns:
The lines, in order (a single ``empty`` note when there are no hops).
@@ -268,7 +270,7 @@ class PathLine:
if not self._hops:
return [Text(self._empty, style="muted")]
budget = max(1, width - indent)
plain = cursor_arrow is not None or self._resolved_mode() != "powerline"
plain = self._resolved_mode() != "powerline"
# The continuation cue is the separator's own mark, minus the space the next
# hop would have sat in: " → " reads " →", a wire spec's "," stays ",".
cue = self._separator.rstrip() if plain else ""
@@ -283,15 +285,11 @@ class PathLine:
groups = legs
lines: list[Text] = []
base = 0 # the global index of the group's first hop, for cursor mapping
for i, group in enumerate(groups):
local: Optional[int] = None
if cursor_arrow is not None and 0 <= cursor_arrow - base < len(group) - 1:
local = cursor_arrow - base
step = 0 if i == 0 else WRAP_OFFSET
line = Text() if i == 0 else Text(" " * (indent + step))
body = self._render(
group, cursor_arrow=local, force_plain=plain,
group, force_plain=plain,
carry_in=bool(i), carry_on=i < len(groups) - 1,
)
# A lone hop too wide for the column is truncated — leaving room for the
@@ -303,12 +301,7 @@ class PathLine:
body.truncate(room, overflow="ellipsis")
line.append_text(body)
if continues:
if cursor_arrow == base + len(group) - 1: # the cursor rides the break
line.append(cue[:-1])
line.append(cue[-1], style="selected")
else:
line.append(cue, style="muted")
base += len(group)
line.append(cue, style="muted")
lines.append(line)
return lines
@@ -478,32 +471,25 @@ class PathLine:
self,
hops: list[PathHop],
*,
cursor_arrow: Optional[int] = None,
force_plain: bool = False,
carry_in: bool = False,
carry_on: bool = False,
) -> Text:
"""Join ``hops`` in the effective mode (plain whenever a cursor is asked).
"""Join ``hops`` in the effective mode.
The two carry flags mark a wrapped line's ends and only mean anything to
chips; arrow mode says the same thing with the trailing cue.
"""
if not force_plain and cursor_arrow is None and self._resolved_mode() == "powerline":
if not force_plain and self._resolved_mode() == "powerline":
return self._render_chips(hops, carry_in=carry_in, carry_on=carry_on)
return self._render_plain(hops, cursor_arrow)
return self._render_plain(hops)
def _render_plain(self, hops: list[PathHop], cursor_arrow: Optional[int]) -> Text:
def _render_plain(self, hops: list[PathHop]) -> Text:
"""Arrow-joined hops — ``path_text``'s presentation, hop by hop."""
text = Text()
for i, hop in enumerate(hops):
if i:
gap = self._separator
if cursor_arrow is not None and i - 1 == cursor_arrow:
text.append(gap[: len(gap) - len(gap.lstrip())])
text.append(gap.strip(), style="selected")
text.append(gap[len(gap.rstrip()) :])
else:
text.append(gap, style="faint" if hop.dim else "muted")
text.append(self._separator, style="faint" if hop.dim else "muted")
text.append_text(self._plain_hop(hop))
return text
@@ -511,7 +497,11 @@ class PathLine:
"""One arrow-mode hop: label in its identity style, annotation muted."""
note_style = "faint" if hop.dim else "muted"
text = Text()
if hop.dim:
if hop.cursor:
# No chip fill to carry the accent, so the slot takes the app's reverse-video
# `selected` — the same block the editor's cursor has always been.
text.append(hop.label, style="selected")
elif hop.dim:
text.append(hop.label, style="faint")
elif hop.style:
text.append(hop.label, style=hop.style)
@@ -610,14 +600,19 @@ class PathLine:
return text
def _chip_fill(self, hop: PathHop) -> str:
"""A chip's fill colour: override, dim slate, white you, hue, keyless grey.
"""A chip's fill: cursor accent, override, dim slate, white you, hue, 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.
The insertion slot outranks everything — it is the one chip that isn't a node,
and it wears the brand accent so it reads as chrome among identities rather than
as a hop with an unlucky hue. The fade comes next, outranking 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.cursor:
return _style_hex("brand") or _KEYLESS_BG
if hop.style:
resolved = _style_hex(hop.style)
if resolved:
@@ -631,9 +626,7 @@ class PathLine:
return _KEYLESS_BG
def _chip(self, hop: PathHop, fill: str) -> Text:
"""One chip: same words as arrow mode, dark ink on the identity fill.
"""
"""One chip: same words as arrow mode, dark ink on the identity fill."""
ink = _DIM_FG if hop.dim else _CHIP_FG
soft = _DIM_FG if hop.dim else _CHIP_FG_SOFT
text = Text()
@@ -670,6 +663,7 @@ def path_line(
dim_from: Optional[int] = None,
hash_as_name: bool = False,
bare_self: bool = False,
cursor: Optional[int] = None,
mode: str = "auto",
) -> PathLine:
"""Build a :class:`PathLine` from raw hop hashes — ``path_text``'s vocabulary.
@@ -707,6 +701,11 @@ def path_line(
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.
cursor: Splice an editor's insertion slot (:data:`CURSOR_GLYPH`) in *at* this
rendered-hop index — the position a chosen hop would take, so index ``0``
opens the route and ``len`` closes it. Counted over rendered hops exactly
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"``).
Returns:
@@ -748,4 +747,6 @@ def path_line(
built.append(PathHop(identity, annotation=note))
else:
built.append(PathHop(compact, key=hop, lit_bytes=prefix_bytes))
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)
+1 -12
View File
@@ -254,7 +254,6 @@ def path_text(
device_hash: Optional[str] = None,
dim_from: Optional[int] = None,
hash_as_name: bool = False,
cursor_arrow: Optional[int] = None,
) -> Text:
"""Render a hop sequence compactly on one line — THE path widget.
@@ -294,11 +293,6 @@ def path_text(
hash_as_name: Present each unnamed hop as its identity hash — muted grey at the
``prefix_bytes`` width, annotated with its addressed byte (``hash_bytes``)
like a named hop — rather than the compact prefix-lit addressed hash.
cursor_arrow: Draw this joining arrow (0-based: arrow *j* joins rendered hops
*j* and *j+1*) as an insertion cursor — the ``→`` glyph in the
reverse-video ``selected`` block, winning over ``dim_from`` — where the
path composer edits its route. ``None`` (everywhere else) draws every
arrow plain.
Returns:
A one-line :class:`Text`. Space tighter than the path is the caller's call,
@@ -312,12 +306,7 @@ def path_text(
for i, hop in enumerate(shown):
dim = dim_from is not None and i >= dim_from
if i:
if cursor_arrow is not None and i - 1 == cursor_arrow:
text.append(" ")
text.append("→", style="selected")
text.append(" ")
else:
text.append(" → ", style="faint" if dim else "muted")
text.append(" → ", style="faint" if dim else "muted")
text.append_text(
_path_node(
hop, resolve, prefix_bytes=prefix_bytes, self_name=self_name,
+6 -6
View File
@@ -55,21 +55,21 @@ def test_paths_screen_renders_graph_rows_and_cursor() -> None:
def test_paths_screen_labels_every_relay_with_its_hash_byte() -> None:
"""Graph relays carry their first hash byte, both paths at once; names stay
in the rows, annotated with the same byte."""
"""Graph relays carry their first hash byte, both paths at once; the rows carry the
names alone — no hash repeated after one, the two tied together by the node's hue."""
screen = _screen(_arrivals())
body = _plain(screen.render_body(76))
graph = body.split("origin →")[0]
for byte in ("3d", "a1", "77"): # every relay labelled, selected or not
assert byte in graph
assert "YUL-Cartier" not in graph and "Waymarker" not in graph
assert "YUL-Cartierville (3d)" in body # the rows carry name + hash byte
assert "Waymarker (a1)" in body
assert "YUL-Cartierville" in body and "Waymarker" in body
assert "(3d)" not in body and "(a1)" not in body # the hex lives on the graph
def test_paths_screen_shows_unknown_relay_as_grey_mode_width_hash() -> None:
"""An unnamed relay stands in its own hash at the device's path-hash width, muted
grey and annotated with its addressed byte — not the bare one-byte prefix, lit."""
grey — not the bare one-byte prefix, lit, and never a hash annotated onto itself."""
now = utcnow()
arrivals = [
Arrival(when=now, hops=("3d63",), snr=4.0), # selected: a named relay
@@ -78,7 +78,7 @@ def test_paths_screen_shows_unknown_relay_as_grey_mode_width_hash() -> None:
screen = _screen(arrivals, prefix_bytes=3) # 3-byte routing → e839f2
lines = screen.render_body(76)
row = next(ln for ln in lines if "e839f2" in _plain([ln]))
assert "e839f2 (e8)" in _plain([row]) # mode-width identity, then the byte
assert "(e8)" not in _plain([row]) # the mode-width identity stands alone
assert "38;2;148;163;184" in row # muted grey over the hash
assert "38;2;94;234;212" not in row # never the brand highlight
graph = _plain(lines).split("origin →")[0]
+41 -23
View File
@@ -14,8 +14,8 @@ import pytest
import meshterm.ui.pathline as pathline
from meshterm.ui.pathline import (
POWERLINE_ROUND_CLOSE, POWERLINE_ROUND_OPEN, POWERLINE_SEP, SELF_GLYPH,
WRAP_OFFSET, PathHop, PathLine, _style_hex, path_line,
CURSOR_GLYPH, POWERLINE_ROUND_CLOSE, POWERLINE_ROUND_OPEN, POWERLINE_SEP,
SELF_GLYPH, WRAP_OFFSET, PathHop, PathLine, _style_hex, path_line,
)
from meshterm.ui.theme import node_style
from meshterm.ui.widgets import path_text
@@ -126,14 +126,18 @@ def test_auto_mode_follows_the_terminal_verdict(monkeypatch: pytest.MonkeyPatch)
assert PathLine(hops).text().plain == "a → b"
def test_cursor_arrow_forces_plain_and_reverse_videos_the_seam() -> None:
"""The composer's insertion cursor needs a seam to sit in, so a cursor render
is always arrows — even in powerline mode — with the cursor arrow selected."""
hops = [PathHop("a"), PathHop("b")]
text = PathLine(hops, mode="powerline").text(cursor_arrow=0)
assert POWERLINE_SEP not in text.plain
assert text.plain == "a → b"
assert _styles(text)["→"] == "selected"
def test_the_cursor_is_a_hop_of_its_own_in_either_mode() -> None:
"""The composer's insertion point is a slot *in* the route, not a gap between two
hops: it renders as a hop, so chips stay chips and arrows stay arrows."""
hops = [PathHop("a"), PathHop(CURSOR_GLYPH, cursor=True), PathHop("b")]
plain = PathLine(hops, mode="plain").text()
assert plain.plain == f"a → {CURSOR_GLYPH} → b"
assert _styles(plain)[CURSOR_GLYPH] == "selected" # the reverse-video block
chips = PathLine(hops, mode="powerline").text()
assert chips.plain == f" a {POWERLINE_SEP * 2} {CURSOR_GLYPH} {POWERLINE_SEP * 2} b {POWERLINE_SEP}"
slot = next(s for s in chips.spans if chips.plain[s.start : s.end] == CURSOR_GLYPH)
assert str(slot.style).endswith(f"on {_style_hex('brand')}") # the brand accent fill
def test_ellipsized_elides_the_middle_and_keeps_both_endpoints() -> None:
@@ -287,19 +291,33 @@ def test_path_line_factory_matches_path_text_hash_as_name_flavour() -> None:
assert path_line([], lambda h: h).text().plain == path_text([], lambda h: h).plain
def test_wrapped_carries_the_cursor_even_onto_a_line_break() -> None:
"""A cursor render is plain even for a powerline line; a cursor sitting mid-group
reverse-videos its arrow, and one sitting exactly on the break rides that line's
trailing cue — the insertion point is always visible."""
hops = [PathHop(label) for label in ("AAAA", "BBBB", "CCCC", "DDDD")]
line = PathLine(hops, mode="powerline")
mid = line.wrapped(16, indent=2, cursor_arrow=0)
assert [text.plain for text in mid] == ["AAAA → BBBB →", " CCCC → DDDD"]
assert any(str(s.style) == "selected" for s in mid[0].spans)
assert all(POWERLINE_SEP not in text.plain for text in mid)
on_break = line.wrapped(16, indent=2, cursor_arrow=1) # the seam that broke
assert str(on_break[0].spans[-1].style) == "selected"
assert not any(str(s.style) == "selected" for s in on_break[1].spans)
def test_path_line_factory_splices_the_cursor_slot_at_a_hop_index() -> None:
"""``cursor`` names the position a chosen hop would take, counted over the rendered
hops and applied after them — so the slot never shifts what its neighbours show."""
hops = [None, "aa11bb", "3d63ab", None]
def built(cursor) -> str: # noqa: ANN001
line = path_line(hops, self_name="Me", bare_self=True, mode="plain", cursor=cursor)
return line.text().plain
assert built(None) == f"{SELF_GLYPH} → aa11bb → 3d63ab → {SELF_GLYPH}"
assert built(0) == f"{CURSOR_GLYPH} → {SELF_GLYPH} → aa11bb → 3d63ab → {SELF_GLYPH}"
assert built(2) == f"{SELF_GLYPH} → aa11bb → {CURSOR_GLYPH} → 3d63ab → {SELF_GLYPH}"
assert built(99).endswith(f"{SELF_GLYPH} → {CURSOR_GLYPH}") # clamped to the tail
def test_wrapped_carries_the_cursor_like_any_other_hop() -> None:
"""A folded route can't strand its insertion point: the slot is a hop, so it lands
on a line of its own accord — wherever it stands, and whatever the mode."""
labels = ("AAAA", "BBBB", "CCCC", "DDDD")
for at in range(len(labels) + 1):
hops = [PathHop(label) for label in labels]
hops.insert(at, PathHop(CURSOR_GLYPH, cursor=True))
lines = PathLine(hops, mode="plain").wrapped(16, indent=2)
assert sum(text.plain.count(CURSOR_GLYPH) for text in lines) == 1
assert all(text.cell_len <= 16 for text in lines)
carried = next(t for t in lines if CURSOR_GLYPH in t.plain)
assert any(str(s.style) == "selected" for s in carried.spans)
def test_wrapped_chip_lines_open_on_the_break_they_continue() -> None:
+19 -14
View File
@@ -23,8 +23,9 @@ 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.services.trace_runner import make_node_resolver
import meshterm.ui.pathline as pathline
from meshterm.ui.path_composer import AUTO_SPEC, FetchNeighbours, PathComposerScreen
from meshterm.ui.pathline import SELF_GLYPH
from meshterm.ui.pathline import CURSOR_GLYPH, SELF_GLYPH
from meshterm.ui.tui.screen import CANCEL
US = "aaaaaaaaaaaa"
@@ -507,18 +508,19 @@ def test_composer_suggestions_follow_the_cursor_anchor() -> None:
assert all(s.node != "3d63c6429436" for s in screen._suggestions())
def test_composer_preview_draws_the_cursor_on_its_arrow() -> None:
"""One reverse-video arrow marks the insertion point, and it rides the cursor."""
def test_composer_preview_stands_the_cursor_in_the_route() -> None:
"""The insertion point is a hop of the route — the ``+`` slot the next node takes —
and ←/→ slide it along, one position at a time."""
screen = _composer(_topo(), hops=["3d63c6429436"], target=False)
def cursor_at() -> list[int]:
text = screen._route_preview().text(cursor_arrow=screen._cursor)
return [s.start for s in text.spans if str(s.style) == "selected"]
def slot_at() -> int:
labels = [hop.label for hop in screen._route_preview().hops]
assert labels.count(CURSOR_GLYPH) == 1 # exactly one slot, always
return labels.index(CURSOR_GLYPH)
(end,) = cursor_at() # exactly one cursor arrow
assert slot_at() == 2 # opens after us and the composed hop: inserting is appending
screen.handle("left")
(home,) = cursor_at()
assert home < end # the block slid left along the preview
assert slot_at() == 1 # slid home, before the hop it would now precede
def test_composer_warns_when_the_walk_repeats_a_link() -> None:
@@ -609,17 +611,20 @@ async def test_composer_commits_spec_auto_and_cancel() -> None:
assert cancelled.future.result() is CANCEL
def test_composer_path_mode_opens_star_to_star_without_auto() -> None:
"""Target-less, the preview is just ``★ → ★`` and there is no Auto action.
def test_composer_path_mode_opens_star_to_star_without_auto(monkeypatch) -> None: # noqa: ANN001
"""Target-less, the preview is just ``★ → + → ★`` and there is no Auto action.
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.
so. Between them waits the empty slot. With no destination there is nothing for the
device to route to either.
"""
monkeypatch.setattr(pathline, "powerline_enabled", lambda: False) # assert the words
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 == f"{SELF_GLYPH} → {SELF_GLYPH}" # our name and hash go unsaid
preview = screen._route_preview().text()
# Our name and hash go unsaid; the slot says where the first hop would land.
assert preview.plain == f"{SELF_GLYPH} → {CURSOR_GLYPH} → {SELF_GLYPH}"
stars = [
str(span.style) for span in preview.spans
if preview.plain[span.start : span.end] == SELF_GLYPH
-12
View File
@@ -112,18 +112,6 @@ def test_path_text_dims_the_tail_from_dim_from() -> None:
assert any(run.startswith("YUL") and style != "faint" for run, style in styles)
def test_path_text_cursor_arrow_reads_as_a_reverse_block() -> None:
"""The composer's insertion cursor: that one arrow renders as a selected block."""
text = path_text(["aa", "77", "3d"], _resolve, cursor_arrow=1)
assert text.plain == "Alice → 77 → YUL" # spacing survives the split append
styles = [(text.plain[s.start : s.end], str(s.style)) for s in text.spans]
assert styles.count(("→", "selected")) == 1 # only arrow 1, between 77 and YUL
assert (" → ", "muted") in styles # the other arrow stays plain
# The cursor wins over dim_from: the arrow into a dimmed tail still reads selected.
dimmed = path_text([None, "aa", None], _resolve, self_name="us", dim_from=2, cursor_arrow=1)
assert any(str(s.style) == "selected" for s in dimmed.spans)
# --- the name→key resolver and the keyless-stays-muted rule --------------------------