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:
1 parent
fc9a459eda
commit
c537fa971e
8 files changed
+161
-150
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 --------------------------
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user