Files
MeshTerm/meshterm/ui/pathline.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

1091 lines
54 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Path lines — the flexible hop-sequence text widget (powerline chips or arrows).
This is the successor to :func:`~meshterm.ui.widgets.path_text` as THE way a hop
sequence renders as text — a trace's walked route, a packet's ``via`` chain, a
message's delivery paths, the composer's preview, a trophy row. Where ``path_text``
is one fixed presentation (arrow-joined, one line, overflow left entirely to the
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 one
solid triangle U+E0B0 interlocked between them — the chip behind as its foreground, the
chip ahead as its background — so a route reads as a ribbon whose segments meet on a
chevron: the oh-my-posh look, at one cell a seam. The chip fill is the node's
hash-derived hue (:func:`~meshterm.ui.theme.node_style`), our own node the map's yellow
``★`` on a neutral dark grey, a faded hop dark slate, a keyless hop grey. Two chips that
land on the *same* fill — a mirrored return leg, a stretch of keyless greys — take the
one exception the interlock can't draw, dropping the background so the page cuts the
wedge (:meth:`PathLine._seam`). Hops elided out of the middle break the ribbon instead
of joining it: the mark sits bare on the page between a closing point and the next
chip's notch (:func:`elision_hop`), because a filled ``⋯`` chip would read as a node by
that name. A chip caught by a *cut* — a lane that ran out, a line scrolled past its
edge — breaks off on a half block in its own fill
(:func:`cut_mark`), and that crack is what says the segment continues; only chips
crack, an arrow line still ellipsizes. A row that also carries the app-wide
opens-further-prompts ``…`` hangs it *outside* the budget
(:func:`with_action_mark`) — chrome may not cost the route cells. ``auto`` (the default)
picks powerline exactly when the terminal can draw it
(: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.
* **Three overflow answers**, matching the three patterns the surfaces already use:
:meth:`PathLine.text` is the full one-liner (for callers that crop it with
:func:`cut_to` or read it through a sliding window of their own, each cut edge
marked by :func:`cut_mark`), :meth:`PathLine.ellipsized` fits a width by eliding
hops behind a ``⋯`` mark, on whichever side it is told to eat into
(:data:`ELIDE_TAIL` by default — both endpoints survive, unlike a plain tail
truncation that amputates the destination; :data:`ELIDE_HEAD` where a surface
wants the head given up whole) — and :meth:`PathLine.wrapped` breaks
at hop boundaries under a hanging indent (never mid-name, never mid-chip),
folding where the route *means* something: the fewest lines it can take, evened
out across them rather than greedily crammed, and preferring the seam where the
path fades from composed to mirrored when that fold is free. A caller that doesn't
know its width — a value dropped into a label/value grid — hands the
:class:`PathLine` itself to Rich and gets the wrapped shape at whatever cell width
the layout works out (see :meth:`PathLine.__rich_console__`).
* **The same hop semantics everywhere.** A :class:`PathHop` carries what the app's
conventions need: the label (a name, or a hash standing as identity), the key any
known prefix of which picks the hue, the ``you`` flag, the trace-flavour
``(3d)`` annotation, the lit-prefix width for hash labels (the
``highlighted_hash`` two-tone), the ``dim`` fade for resolved return legs, and an
explicit style override for context colourings that outrank identity. Chips keep
the exact same words as arrows — only colours and separators change — so a route
reads identically whichever mode drew it. A surface whose route always starts and
ends on us can ask for ``bare_self``, replacing our name and hash with the app-wide
``★`` — the reader already knows who both ends are, and the cells belong to the hops
that *are* news. Those stars fade where the route is being *composed* (our two ends
are fixtures, not choices); a surface that only *shows* a walk passes ``dim_self=False``
and keeps us in the ``you`` white.
Existing call sites still render through ``path_text``; migrating them here is a
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
import re
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from rich.cells import cell_len
from rich.console import Console, ConsoleOptions, RenderResult
from rich.measure import Measurement
from rich.text import Text
from ..core.models import LOCAL_DEVICE_LABEL
from .marks import SELF_MARK
from .termfont import powerline_enabled, powerline_full
from .theme import active_theme, node_style
#: The powerline solid right-pointing triangle (U+E0B0) — the only glyph the widget
#: draws, seam and closing edge alike, deliberately from the *core* set so every
#: recommended font qualifies (the rounded caps at U+E0B4+ exist only in full Nerd
#: Font patches). A path only ever flows one way, so the point only ever faces right;
#: what changes is whether it lands on a field (a seam) or on the page (an edge).
POWERLINE_SEP = "\ue0b0"
#: The rounded caps (U+E0B6 opening, U+E0B4 closing) that finish a path's two outer
#: ends as a lozenge. These live in the *extended* block, which only a full Nerd Font
#: patch carries (:func:`~meshterm.ui.termfont.powerline_full`) — a terminal with just
#: the core four squares those ends off instead of showing tofu.
POWERLINE_ROUND_OPEN = ""
POWERLINE_ROUND_CLOSE = ""
#: Our own node, wherever a surface asks for it stripped of name and hash
#: (``bare_self``): the app-wide ``★`` — literally the map's own marker, taken from
#: :data:`~meshterm.ui.marks.SELF_MARK` so the star in a route and the star on the map
#: can never drift apart, in glyph or in hue.
SELF_GLYPH, _SELF_INK = SELF_MARK
#: 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 = " → "
#: The theme name a rendered path line wears as its base style: it draws *nothing* and
#: exists to be recognised. Every shape this widget mints is stamped with it (see
#: :meth:`PathLine._render`), so a row that appends a path carries a span saying where the
#: route lies — which is how the cursor row's identity fold knows to spare the hop hues
#: (:func:`~meshterm.ui.tui.render._whiten_identities`) instead of whitening a whole route.
PATH_INK = "pathline"
#: Chip ink: near-black slate for readable text on every spectrum hue and on white.
_CHIP_FG = "#0f172a"
#: Chip ink, softened — annotations and a hash label's unlit tail (the two-tone).
_CHIP_FG_SOFT = "#334155"
#: Chip fill for a keyless hop — the ``faint`` grey; colour stays reserved for
#: keyed identities in chips just as it is in arrow mode.
_KEYLESS_BG = "#64748b"
#: Our own node's chip fill: a neutral dark grey, so the yellow ``★`` riding it reads as
#: the map's marker rather than as a node's hue (JP, 2026-08-09). Deliberately *not* a
#: spectrum colour and not the white it used to be — our end of a route is a fixture the
#: reader already knows, so it sits back and lets the hops that differ carry the colour.
_YOU_BG = "#3f3f46"
#: A dimmed hop's chip: dark slate fill with muted ink, receding like ``faint`` text.
_DIM_BG = "#334155"
_DIM_FG = "#94a3b8"
#: The mark standing in for elided hops (see :meth:`PathLine.ellipsized`) — rendered as
#: a dim pseudo-hop so it recedes in both modes.
_ELISION = "⋯"
#: The classic truncation mark, and what an *arrow* line still cuts with: three dots
#: standing where words were. An arrow hop is text, so shortening it is what happened.
_ELLIPSIS = "…"
#: The app-wide "this row opens further prompts" mark (see the UX standards), as a *path*
#: row wears it — see :func:`with_action_mark` for why it is spelled here rather than
#: appended by each surface.
ACTION_MARK = " …"
#: What a *chip* line cuts with instead (JP, 2026-08-09) — the half block, drawn in the
#: cut chip's own fill with no background, so the cell is half segment and half bare
#: page: the chip breaks off mid-body and the crack says it is incomplete. An ellipsis
#: would claim the opposite, that a *word* was shortened, when what ran out is the lane.
#: :data:`CRACK_TAIL` closes a line the path runs on past (the fill's left half survives,
#: the page takes the right); :data:`CRACK_HEAD` is its mirror, opening a line whose
#: start is off-screen. Only chips crack — :func:`cut_mark` finds a fill or falls back to
#: :data:`_ELLIPSIS`, so arrow lines are untouched by the whole idea.
CRACK_TAIL = "▐"
CRACK_HEAD = "▌"
#: Which side of a path an :meth:`PathLine.ellipsized` fit eats into. ``ELIDE_TAIL`` is
#: the route reading — the origin anchors the line, the destination is rescued off the
#: far end, and the mark lands in the middle. ``ELIDE_HEAD`` is the trail reading — the
#: last hop anchors the line and everything before it goes, origin included.
ELIDE_TAIL = "tail"
ELIDE_HEAD = "head"
#: Extra columns a wrapped line steps in past the hanging indent. The continuation
#: marks (a trailing ``→``, a notched chip edge) say the path goes on; the step says
#: it at a glance, from the shape of the block alone — a route that folds reads as one
#: value that ran long, not as a second value stacked under the first.
WRAP_OFFSET = 2
@dataclass(frozen=True)
class PathHop:
"""One hop of a path line — the semantics, independent of how it is drawn.
Attributes:
label: What is shown — a resolved name, a hash standing as the node's
identity (set ``lit_bytes`` for the two-tone prefix in that case), or the
bare :data:`SELF_GLYPH` where a surface has no need to name us.
key: Any known prefix of the node's key/hash — picks the hash-derived hue
(its first byte, so every prefix agrees). ``None`` renders muted/grey:
colour is reserved for keyed identities.
you: Our own node. Arrows draw it in the pure-white ``you`` style; chips draw it
in the map's own yellow on the neutral dark grey :data:`_YOU_BG`, padded like
every other chip.
annotation: The trace-flavour hash note, rendered ``" (3d)"`` after the
label in both modes (pass the bare ``3d``, no parentheses).
lit_bytes: For a hash label: leading *bytes* drawn in the hue (the
``highlighted_hash`` two-tone), the rest muted/soft. ``0`` for names.
dim: Fade the hop (and the arrow leading into it) — a planned route's
resolved return leg, "not yours to compose".
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 ``cursor`` white, 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.
gap: This hop is a mark standing *between* two nodes rather than being one — the
elision (:func:`elision_hop`). Chips draw it in the break between two
segments, bare on the page with no fill and no padding, because what it says
is "the ribbon stops here and picks up again"; a chip would say the opposite,
that some node in the route is called ``⋯``. Arrow mode needs no special case:
a bare label between two arrows is already exactly that.
"""
label: str
key: str | None = None
you: bool = False
annotation: str | None = None
lit_bytes: int = 0
dim: bool = False
style: str | None = None
cursor: bool = False
gap: bool = False
def elision_hop() -> PathHop:
"""The mark standing in for hops dropped out of a path's middle (or its head).
THE way to say "hops were left out here", so every surface that shortens a path
agrees on the glyph *and* on the fact that it is a gap rather than a node (see
:attr:`PathHop.gap`).
"""
return PathHop(_ELISION, dim=True, gap=True)
def _style_hex(style: str) -> str | None:
"""The ``#rrggbb`` a style string or theme name resolves to, else ``None``."""
for token in reversed(style.split()):
if token.startswith("#") and len(token) == 7:
return token
themed = active_theme().styles.get(style)
if themed is not None and themed.color is not None:
try:
return "#" + themed.color.get_truecolor().hex.lstrip("#")
except Exception:
return None
return None
#: A chip's fill, as it appears in a rendered span's style — the ``on #rrggbb`` half.
#: Only :meth:`PathLine._chip` sets a background, which is what makes this the test for
#: "is there a chip here": a seam's point, a notch, and every arrow-mode span carry a
#: foreground alone.
_ON_FILL = re.compile(r"\bon\s+(#[0-9a-fA-F]{6})\b")
def _fills(line: Text) -> list[str | None]:
"""The chip fill under each *character* of a rendered line (``None`` off a chip)."""
fills: list[str | None] = [None] * len(line.plain)
for span in line.spans:
style = span.style if isinstance(span.style, str) else str(span.style)
found = _ON_FILL.search(style)
if found is None:
continue
for i in range(max(0, span.start), min(len(fills), span.end)):
fills[i] = found.group(1)
return fills
def _char_of_cell(plain: str, cell: int) -> int:
"""The character index a display cell falls in, clamped to the string's ends."""
if cell <= 0:
return 0
at = 0
for i, ch in enumerate(plain):
at += cell_len(ch)
if at > cell:
return i
return max(0, len(plain) - 1)
def cut_mark(line: Text, at: int, side: str) -> Text:
"""The one cell that says a rendered path line was cut here.
A chip caught by the cut breaks off in its own colour (:data:`CRACK_TAIL` /
:data:`CRACK_HEAD`) — half the cell is still segment, half is bare page, so the
reader sees a chip that *continues* rather than a route that ended. A line drawn in
arrows has no fill to shear, so it cuts the classic way, with :data:`_ELLIPSIS`; that
fallback is the whole mode test, and it costs the caller nothing to ask for.
The fill is read from the nearest cell *inside* the line — scanning back from ``at``
for a tail cut, forward for a head cut — so a cut landing on a seam or on the closing
edge still cracks in the colour of the chip the reader can see, not in nothing.
Args:
line: The rendered line being cut (whole, unshifted — cells are absolute).
at: The cell index of the nearest *visible* cell on the mark's side: the last
one still drawn for :data:`ELIDE_TAIL`, the first for :data:`ELIDE_HEAD`.
side: Which end of the line the mark closes — :data:`ELIDE_TAIL` (the path runs
on to the right) or :data:`ELIDE_HEAD` (it began off to the left).
Returns:
The styled single cell, ready to append or prepend.
"""
fills = _fills(line)
if not fills: # nothing drawn to shear — a caller cutting an empty line
return Text(_ELLIPSIS, style="muted")
index = _char_of_cell(line.plain, at)
steps = range(index, len(fills)) if side == ELIDE_HEAD else range(index, -1, -1)
fill = next((fills[i] for i in steps if fills[i]), None)
if fill is None:
return Text(_ELLIPSIS, style="muted")
return Text(CRACK_HEAD if side == ELIDE_HEAD else CRACK_TAIL, style=fill)
def with_action_mark(line: Text, width: int) -> Text:
"""``line`` plus the opens-further-prompts ``…`` — riding *past* the width budget.
The mark says what Enter on the row does. It is not part of the path, so it must not
cost the path a cell: reserving room for it up front made a route that fitted its lane
exactly crack on its last chip (JP, 2026-08-10), so the row claimed the walk ran on
when the walk had in fact finished — the crack only replaces the closing cap, and the
two cells the mark wanted were the two the reader lost.
So the path is fitted to the *whole* lane first and the mark lands in whatever is left
over; where nothing is left, it simply isn't drawn. Losing it costs the reader a hint
the row's own behaviour gives them the moment they press Enter; losing the tail of a
hop costs them the route. That is the widget's default answer wherever a path row
carries the mark, not one surface's local trade — pass the *fitted* line here (after
:func:`cut_to` or after a scroll window) and let the leftovers decide.
Args:
line: The path line, already fitted to ``width``.
width: The lane's cell budget.
Returns:
The line with the mark appended, or unchanged when the lane has no room for it.
"""
if line.cell_len + cell_len(ACTION_MARK) > width:
return line
out = line.copy()
out.append(ACTION_MARK, style="muted")
return out
def cut_to(line: Text, width: int, *, action: bool = False) -> Text:
"""Fit a rendered path line to ``width`` by cutting its tail — cracked, not elided.
The drop-in for ``Text.truncate(width, overflow="ellipsis")`` wherever the line being
cut is a path: the body is cropped a cell short and the freed cell carries
:func:`cut_mark`, so chips break off in their own colour and arrow lines end in the
same ``…`` they always did. A line that already fits comes back untouched.
Args:
line: The rendered line to fit.
width: The cell budget.
action: Append the opens-further-prompts mark afterwards, free of the budget
(:func:`with_action_mark`) — for a row Enter opens something from.
Returns:
A line no wider than ``width``.
"""
if width <= 0:
return Text()
if line.cell_len <= width:
fitted = line
else:
mark = cut_mark(line, width - 2, ELIDE_TAIL)
fitted = line.copy()
fitted.truncate(max(0, width - mark.cell_len), overflow="crop")
fitted.append_text(mark)
return with_action_mark(fitted, width) if action else fitted
def hops_atom(count: int) -> Text:
"""The hop count, as the stats line hanging under a path line spells it.
Wherever a surface hangs stats under a path, the number of hops is one of them (JP,
2026-08-10): it is the figure the picture above encodes but never states, the one two
routes are most often compared on, and the one a reader would otherwise have to count
chips to get. ``direct`` where there are none — a path with no relays isn't "0 hops",
it is the app's own word for a frame that went straight there (:class:`PathLine`'s
own ``empty`` note says exactly that).
Args:
count: How many *relay* hops the route took (endpoints excluded — us and the far
node are on every route here, so counting them would say nothing).
Returns:
The muted atom, ready to join a ``·`` chain.
"""
if count <= 0:
return Text("direct", style="muted")
return Text(f"{count} hop" if count == 1 else f"{count} hops", style="muted")
class PathLine:
"""A hop sequence, renderable as arrow-joined text or powerline chips.
Build one from :class:`PathHop` entries, then ask for the shape the surface
needs: :meth:`text` (full line), :meth:`ellipsized` (middle-elided to a width),
or :meth:`wrapped` (hop-boundary lines under a hanging indent).
Args:
hops: The hops in display order (the widget never reorders; a reversed or
mirrored walk is the caller's composition).
mode: ``"auto"`` (powerline when the terminal can draw it — the default),
``"powerline"``, or ``"plain"``.
separator: The plain-mode joiner. Defaults to the app-wide ``" → "``; a
surface with its own convention (the mesh walk trail's ``" › "``) may pass
it. Ignored by powerline mode.
empty: The muted text a hopless path reads as (``"direct"``).
"""
def __init__(
self,
hops: Sequence[PathHop],
*,
mode: str = "auto",
separator: str = _ARROW,
empty: str = "direct",
) -> None:
"""Hold the hops and the drawing choices; nothing is measured or drawn yet.
Every argument is described in the class docstring above.
"""
self._hops = list(hops)
self._mode = mode
self._separator = separator
self._empty = empty
@property
def hops(self) -> list[PathHop]:
"""The hops, for callers that splice lines together (endpoints + relays)."""
return list(self._hops)
# --- the three shapes ---------------------------------------------------------
def text(self) -> Text:
"""The full one-line rendering; overflow is the caller's call, per surface.
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)
def ellipsized(self, width: int, *, elide: str = ELIDE_TAIL) -> Text:
"""The line fitted to ``width`` by eliding hops behind a ``⋯`` mark.
``elide`` names the *side* the mark eats into — which end of the path pays for
the overflow:
* :data:`ELIDE_TAIL` (the default) anchors the line on its head: the origin
holds and hops go from the tail end. The destination is rescued out of that
side along with as much of the run before it as fits, so the ``⋯`` settles in
the *middle* and both endpoints survive — a route reads origin and
destination first, and a plain right-truncation would amputate the second.
The origin is only given up if even that won't fit, and a lone over-long hop
is cut where the width runs out as the last resort (:func:`cut_to` — a chip
cracks off in its own colour, an arrow line ellipsizes).
* :data:`ELIDE_HEAD` anchors the line on its tail: the last hop holds and hops
go from the head end, the origin among them, with no endpoint rescued. A
breadcrumb trail's news is where the walk *is*; where it set out from is
exactly the part worth losing.
Args:
width: The cell budget the returned line must fit in.
elide: Which side the ``⋯`` eats into — :data:`ELIDE_TAIL` (the default,
keeping both endpoints) or :data:`ELIDE_HEAD` (keeping the tail alone).
Returns:
A one-line :class:`Text` no wider than ``width``.
"""
full = self.text()
if full.cell_len <= width or not self._hops:
return full
mark = elision_hop()
count = len(self._hops)
# The heads worth trying, most-rescued first: a tail-side elision spares the
# origin while it fits and gives it up only when it must; a head-side one is
# eating the head on purpose, so it never spares it at all.
for head in (0,) if elide == ELIDE_HEAD else (1, 0):
for tail in range(count - 1 - head, 0, -1):
kept = self._hops[:head] + [mark] + self._hops[-tail:]
candidate = self._render(kept)
if candidate.cell_len <= width:
return candidate
last = self._render([mark, self._hops[-1]]) if count > 1 else full
return cut_to(last, width)
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
line, so that line is returned *without* the indent prefix while every
continuation starts with ``indent`` spaces — plus :data:`WRAP_OFFSET`, the
step that makes a fold legible as a fold. Every line fits ``width``.
Plain-mode lines that
continue end with the separator's own mark — a trailing ``→`` for an
arrow-joined path, the bare ``,`` for a comma-joined wire spec — the "path
goes on" cue; chip lines always close with their pointed edge and, from the
second line down, open by redrawing the seam the break interrupted — the point
arriving out of the previous line's last fill — so a continuation is never
mistakable for a path starting over. A single hop wider than the content column
stands alone, cut to it by :func:`cut_to`: a chip breaks off on the crack, an
arrow hop ellipsizes.
Where the breaks fall is chosen for how it *reads* (see :meth:`_flow` and
:meth:`_turn_seam`), not by cramming each line full: the fold takes the
fewest lines the hops allow, spread evenly across them, and lands on the
route's turn — where a leg gives way to its mirrored return — when folding
there costs no extra line.
Args:
width: The full line budget, indent included.
indent: The hanging-indent column the continuations align under.
Returns:
The lines, in order (a single ``empty`` note when there are no hops).
"""
if not self._hops:
return [Text(self._empty, style="muted")]
budget = max(1, width - indent)
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 ""
groups = self._flow(self._hops, budget, plain, len(cue), 0)
seam = self._turn_seam()
if seam is not None and len(groups) > 1:
# The outbound leg's own last line still continues into the return, so it
# holds the cue back where the whole path's final line doesn't.
legs = self._flow(self._hops[:seam], budget, plain, len(cue), len(cue))
legs += self._flow(self._hops[seam:], budget, plain, len(cue), 0, carry_in=True)
if len(legs) <= len(groups): # the turn folds for free — take it
groups = legs
lines: list[Text] = []
for i, group in enumerate(groups):
step = 0 if i == 0 else WRAP_OFFSET
line = Text() if i == 0 else Text(" " * (indent + step))
body = self._render(
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
# cue it still has to carry, so even that line stays inside the width. A
# column too narrow to hold both drops the cue: content wins the cells.
continues = i < len(groups) - 1 and 0 < cell_len(cue) < budget
room = budget - step - (cell_len(cue) if continues else 0)
if body.cell_len > room:
body = cut_to(body, room)
line.append_text(body)
if continues:
line.append(cue, style="muted")
lines.append(line)
return lines
# --- the shape a Rich layout asks for -----------------------------------------
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
"""Render into a Rich layout — a table cell, a group — as the wrapped shape.
The one shape a caller doesn't have to size itself. Dropped straight into a
label/value grid (the packet viewer's ``via`` row), the line folds at hop
boundaries to whatever cell width the layout works out for the value column,
hanging its continuations under the value block exactly as :meth:`wrapped`
lays them out — so a surface whose lane width is decided *by* the other rows
never has to compute it twice. A caller that already knows its width still
asks for a shape directly.
"""
yield from self.wrapped(max(1, options.max_width))
def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement:
"""How wide the line wants to be: the whole one-liner, down to one hop's cells.
The maximum is the unwrapped line — given the room, a route reads best without
folding at all. The minimum is the widest single hop plus the chrome every line
carries (a chip's closing edge, a continuation's reopened seam, the
:data:`WRAP_OFFSET` step): below that no column can hold a hop whole, so a
narrower lane is the layout choosing to crop rather than to wrap.
"""
full = self.text().cell_len
if not self._hops:
return Measurement(full, full)
cells, _join, tail, lead, _head = self._measure(
self._hops, self._resolved_mode() != "powerline"
)
least = max(cells) + tail + lead + WRAP_OFFSET
return Measurement(min(least, full), full)
# --- where the breaks fall --------------------------------------------------------
def _measure(self, hops: list[PathHop], plain: bool) -> tuple[list[int], int, int, int, int]:
"""Each hop's own width, plus what joining, opening and closing a line costs.
Fitting hops to a column is arithmetic once every hop has been measured once —
which matters because :meth:`_flow` tries a whole series of columns, and the
screens re-fit on every repaint (a spinner tick included).
Args:
hops: The hops to measure, in order.
plain: Measure as arrow text rather than as chips.
Returns:
``(cells, join, tail, lead, head)`` — each hop's cells, the cells one join
between two hops costs (chip mode's seam is the single interlocked chevron —
and so is either side of an elision gap, so the figure holds there too), the
cells a line always closes with (chip mode's edge; nothing in arrow mode), the
cells every line *after the first* opens with (chip mode's notch, the seam
redrawn), and the cells the *first* line opens with (chip mode's rounded cap,
nothing where the font has none).
"""
if plain:
plain_cells = [self._plain_hop(hop).cell_len for hop in hops]
return plain_cells, cell_len(self._separator), 0, 0, 0
widths = [
cell_len(hop.label) if hop.gap else self._chip(hop, self._chip_fill(hop)).cell_len
for hop in hops
]
sep = cell_len(POWERLINE_SEP)
head = cell_len(POWERLINE_ROUND_OPEN) if powerline_full() else 0
return widths, sep, sep, sep, head
@staticmethod
def _fill(
cells: list[int],
join: int,
tail: int,
budget: int,
reserve: int,
last: int,
lead: int = 0,
head: int = 0,
) -> list[int]:
"""Pack hop widths into lines of ``budget`` cells, greedily, never splitting one.
Args:
cells: Each hop's width, in order (see :meth:`_measure`).
join: Cells one join between two hops costs.
tail: Cells every line closes with.
budget: The content column's width in cells.
reserve: Cells held back on a line for the continuation cue it will carry.
last: Cells held back on a line ending at the *final* hop — ``0`` when the
path really ends there (nothing follows to cue), ``reserve`` when this
is only one leg of a path that goes on.
lead: Cells every line but the first opens with — the reopened seam plus
the :data:`WRAP_OFFSET` step it hangs past the indent.
head: Cells the first line opens with (the rounded cap, when drawn).
Returns:
How many hops each line takes; a hop too wide for ``budget`` gets a line of
its own (the caller truncates it).
"""
sizes: list[int] = []
count = 0
width = head
for i, cell in enumerate(cells):
grown = width + cell + (join if count else 0)
held = last if i == len(cells) - 1 else reserve
if count and grown + tail + held > budget:
sizes.append(count)
width, count = lead + cell, 1
else:
width, count = grown, count + 1
sizes.append(count)
return sizes
def _flow(
self,
hops: list[PathHop],
budget: int,
plain: bool,
reserve: int,
last: int,
carry_in: bool = False,
) -> list[list[PathHop]]:
"""Break ``hops`` into the fewest lines, then even those lines out.
Greedy packing alone widows the tail: a route whose last hop misses the first
line by a cell or two strands ``us`` alone underneath a full one. Since the
line *count* is what costs screen rows — and greedy already achieves the
minimum — the same count is re-packed at the narrowest column that still
needs it, which spreads the hops across the lines instead of cramming the
first and starving the last (``us → a → b → c →`` / ``us`` becomes
``us → a →`` / ``b → c → us``).
Args:
hops: The hops to break up, in order.
budget: The content column's width in cells.
plain: Render as arrows rather than chips.
reserve: Cells held back on a line for the continuation cue it carries.
last: Cells held back on the line the hops end on (see :meth:`_fill`).
carry_in: These hops are a *later* leg of a path already under way, so
even their first line opens as a continuation, not as a beginning.
Returns:
The hops grouped per line.
"""
cells, join, tail, lead, head = self._measure(hops, plain)
lead += WRAP_OFFSET # every continuation steps in past the hanging indent
if carry_in:
head = lead
lines = len(self._fill(cells, join, tail, budget, reserve, last, lead, head))
low, high = 1, budget
while low < high: # the narrowest column still taking `lines` rows
mid = (low + high) // 2
if len(self._fill(cells, join, tail, mid, reserve, last, lead, head)) <= lines:
high = mid
else:
low = mid + 1
groups: list[list[PathHop]] = []
at = 0
for size in self._fill(cells, join, tail, low, reserve, last, lead, head):
groups.append(hops[at : at + size])
at += size
return groups
def _turn_seam(self) -> int | None:
"""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`). 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 faded tail is the
automatic landing back on us never widows it onto a line of its own; that lone
hop marks no turn, and the walk's own mirror still gets to name one.
"""
seam: int | None = 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]:
seam = len(marks) // 2 + 1 # the first hop of the way home
if seam is None or seam < 2 or len(self._hops) - seam < 2:
return None
return seam
# --- rendering ----------------------------------------------------------------
def _resolved_mode(self) -> str:
"""The effective mode: ``auto`` resolves against the terminal's verdict."""
if self._mode != "auto":
return self._mode
return "powerline" if powerline_enabled() else "plain"
def _render(
self,
hops: list[PathHop],
*,
force_plain: bool = False,
carry_in: bool = False,
carry_on: bool = False,
) -> Text:
"""Join ``hops`` in the effective mode, marked as a path line.
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.
Every shape leaves through here, so this is where the line stamps its own extent:
the returned text's base style is :data:`PATH_INK`, which draws nothing and says
"these cells are a route". A row that appends it (``Text.append_text``) carries
that stamp along as a span over the hops, and the cursor row's identity fold reads
it there — see :func:`~meshterm.ui.tui.render._whiten_identities`. Inside a path
line the hue is not decoration on a name, it is what tells one hop from the next,
so the highlight lights the row without eating it.
"""
if not force_plain and self._resolved_mode() == "powerline":
line = self._render_chips(hops, carry_in=carry_in, carry_on=carry_on)
else:
line = self._render_plain(hops)
line.style = PATH_INK
return line
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:
text.append(self._separator, style="faint" if hop.dim else "muted")
text.append_text(self._plain_hop(hop))
return text
def _plain_hop(self, hop: PathHop) -> Text:
"""One arrow-mode hop: label in its identity style, annotation muted."""
note_style = "faint" if hop.dim else "muted"
text = Text()
if hop.cursor:
# No chip fill to carry the slot's white, so it takes the app's reverse-video
# `selected` grey — the same block a dialog's committing button is drawn as,
# and 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)
elif hop.you:
text.append(hop.label, style="you")
elif hop.key and hop.lit_bytes > 0:
split = hop.lit_bytes * 2 # the highlighted_hash two-tone, in hex digits
text.append(hop.label[:split], style=node_style(hop.key))
text.append(hop.label[split:], style="muted")
elif hop.key:
text.append(hop.label, style=node_style(hop.key))
else:
# No key to derive a hue from: the hop is a bare hash standing in for a node
# we can't identify, so it takes the app-wide unknown-node grey — not `muted`,
# which is chrome and sits a step darker on a console with only two greys.
text.append(hop.label, style="node.unknown")
if hop.annotation:
text.append(f" ({hop.annotation})", style=note_style)
return text
def _render_chips(
self, hops: list[PathHop], *, carry_in: bool = False, carry_on: bool = False
) -> Text:
"""Powerline chips: each hop filled with its hue, a two-cell gap at every seam.
The two outer ends say whether this line *is* the path or only part of it. A
line that opens the path opens rounded (squared off where the font has no
rounded caps); one that continues a wrapped path opens on the break's other
half — which is exactly the seam's own second cell, so a fold looks like the
seam it interrupted. A line that ends the path closes rounded; one the path
outruns closes on the point, the same "goes on" cue arrow mode spells with a
trailing ``→``.
An elision (:attr:`PathHop.gap`) interrupts the ribbon rather than joining it: the
chip before it closes on the page, the mark sits on the page bare, and the chip
after it opens on its notch — the picture of a route whose middle was left out,
where a filled ``⋯`` chip would have read as a node by that name.
Args:
hops: The hops of this one line, in order.
carry_in: This line continues a wrapped path (it does not open one).
carry_on: The path continues past this line (it does not end here).
"""
fills = [self._chip_fill(hop) for hop in hops]
rounded = powerline_full()
text = Text()
if carry_in:
text.append_text(self._notch(fills[0])) # the break's other half
elif rounded and not hops[0].gap:
text.append(POWERLINE_ROUND_OPEN, style=fills[0])
for i, hop in enumerate(hops):
if i:
if hops[i - 1].gap:
text.append_text(self._notch(fills[i])) # the ribbon picks up again
elif hop.gap:
text.append(POWERLINE_SEP, style=fills[i - 1]) # …and stops here
else:
text.append_text(self._seam(fills[i - 1], fills[i]))
if hop.gap:
text.append(hop.label, style="faint" if hop.dim else "muted")
else:
text.append_text(self._chip(hop, fills[i]))
if hops[-1].gap:
return text # the line ended on the page; there is no chip left to close
close = POWERLINE_SEP if carry_on or not rounded else POWERLINE_ROUND_CLOSE
text.append(close, style=fills[-1]) # the edge into the page: pointed or round
return text
@staticmethod
def _seam(before: str, after: str) -> Text:
"""The one cell between two chips: the previous fill's point, laid on the next.
The classic interlock — foreground the chip behind, background the chip ahead —
so the route reads as one ribbon whose segments meet on a chevron. It costs a
single cell, and with 256 node hues on the wheel two neighbours are near enough
to always differ that the join reads as a join (JP, 2026-08-09; the widget used
to spend two cells leaving a sliver of page between every pair, insurance against
a collision that the palette makes rare).
Two fills that *do* land the same are the exception the interlock cannot draw —
a chevron in its own background is no chevron — and they are not always bad luck:
a mirrored return leg is a run of identically faded hops by construction, and so
is a stretch of keyless greys. There the point drops its background and the page
shows through the wedge instead, which is the one thing that still separates two
blocks of one colour. One cell either way.
Args:
before: The fill the point is drawn in (the previous chip's).
after: The fill it is laid on (the next chip's).
Returns:
The seam's single styled cell.
"""
if before == after:
return Text(POWERLINE_SEP, style=before) # fg only — the page cuts the wedge
return Text(POWERLINE_SEP, style=f"{before} on {after}")
@staticmethod
def _notch(fill: str) -> Text:
"""A chip's left edge: the point cut inward out of its own fill.
Reverse video paints the notch in the terminal's own background — the only way
to name the page's colour without knowing it — so nothing of whatever sits left
of the chip (the previous chip's taper, the line above a wrapped break) bleeds
into it, and the point still faces the way the path flows.
Args:
fill: The chip's fill colour, which the notch is cut out of.
Returns:
The one styled cell.
"""
text = Text()
text.append(POWERLINE_SEP, style=f"{fill} reverse")
return text
def _chip_fill(self, hop: PathHop) -> str:
"""A chip's fill: cursor accent, override, dim slate, our grey, hue, keyless grey.
The insertion slot outranks everything — it is the one chip that isn't a node,
and it wears the ``cursor`` white so it reads as chrome among identities rather
than as a hop with an unlucky hue (white sits outside the node spectrum, and the
chip ink is already picked to stay readable on it). 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 our end must recede with the rest of that automatic
half rather than keep its yellow among the greys. Plain mode says the same thing
by fading the name.
"""
if hop.cursor:
return _style_hex("cursor") or _KEYLESS_BG
if hop.style:
resolved = _style_hex(hop.style)
if resolved:
return resolved
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
def _chip(self, hop: PathHop, fill: str) -> Text:
"""One chip: same words as arrow mode, dark ink on the identity fill.
Every chip is padded on both sides, the ``★`` included (JP, 2026-08-09): the pads
are not there to make room for a long *word*, they are the chip's own shape, and a
star squeezed between two chevrons reads as a glyph wedged into the seam rather
than as a segment of the route. Our own chip's ink is the map's yellow on the
neutral dark grey :data:`_YOU_BG`, so the star in the line and the star on the map
read as one mark.
"""
ink = _DIM_FG if hop.dim else (_SELF_INK if hop.you else _CHIP_FG)
soft = _DIM_FG if hop.dim else _CHIP_FG_SOFT
text = Text()
text.append(" ", style=f"on {fill}")
if hop.lit_bytes > 0 and not hop.dim:
split = hop.lit_bytes * 2
text.append(hop.label[:split], style=f"bold {ink} on {fill}")
text.append(hop.label[split:], style=f"{soft} on {fill}")
else:
weight = "" if hop.dim else "bold "
text.append(hop.label, style=f"{weight}{ink} on {fill}")
if hop.annotation:
text.append(f" ({hop.annotation})", style=f"{soft} on {fill}")
text.append(" ", style=f"on {fill}")
return text
def _shorten(value: str, hash_bytes: int | None) -> str:
"""Bare lowercase hex, truncated to ``hash_bytes`` bytes (whole when falsy)."""
raw = value.lower().removeprefix("0x")
return raw[: hash_bytes * 2] if hash_bytes else raw
def path_line(
hops: Sequence[str | None],
resolve: Callable[[str], str | None] = lambda hop: hop,
*,
prefix_bytes: int = 0,
self_name: str | None = None,
empty: str = "direct",
show_hash: bool = False,
hash_bytes: int | None = None,
device_hash: str | None = None,
dim_from: int | None = None,
hash_as_name: bool = False,
bare_self: bool = False,
dim_self: bool = True,
cursor: int | None = None,
mode: str = "auto",
) -> PathLine:
"""Build a :class:`PathLine` from raw hop hashes — ``path_text``'s vocabulary.
The migration bridge: every parameter means exactly what it means to
:func:`~meshterm.ui.widgets.path_text` (see there for the full rendering rules),
so a call site swaps builders without changing what it says — a named hop reads
``Name`` (annotated ``Name (3d)`` under ``show_hash``), an unnamed hop its hash in
the unknown-node grey (colour marks an identified node, as in the path graph),
``None`` is our own device in white — or, under ``bare_self``, the lone ``★`` — and
``dim_from`` fades a resolved tail. The plain rendering
is character- and style-identical to ``path_text``; what the swap buys is the
:class:`PathLine` shapes (ellipsized / wrapped) and the powerline mode.
Args:
hops: The hops in propagation order — hex hashes, ``None`` marking our own
device (empty strings are skipped; ``dim_from`` counts rendered hops).
resolve: Maps a hop hash to a friendly name when known.
prefix_bytes: Path-hash width an unnamed hop's identity hash presents at under
``hash_as_name`` (unnamed hops are otherwise shown grey whole, no prefix lit).
self_name: Our own node's name — white when a resolved name matches it, and
naming any ``None`` device hop.
empty: The muted text shown when there are no hops (e.g. ``"direct"``).
show_hash: Annotate named hops (and, with ``device_hash``, our device) with
their hash in parentheses — the trace presentation.
hash_bytes: Truncate shown/annotated hashes to this byte width.
device_hash: Our own device's key, annotated onto ``None`` hops when
``show_hash`` is on.
dim_from: Fade hops at/after this rendered index (``None`` dims nothing).
hash_as_name: Present each unnamed hop as its muted identity hash at the
``prefix_bytes`` width, annotated with its addressed byte.
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.
dim_self: Fade the bare stars (the default). On a surface where the route is
something you *compose*, setting out from us and coming home to us are
fixtures — no more yours to choose or remove than a mirrored return leg —
so they wear the same automatic grey, and the colour on the line belongs to
the nodes actually being picked. Pass ``False`` where nothing is being
composed (a record of a walk already made): there is no "not yours" to say,
and our node then reads in the app-wide ``you`` white like everywhere else.
A ``dim_from`` that reaches a star still fades it either way.
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:
The assembled :class:`PathLine`.
"""
shown = [h for h in hops if h is None or h]
built: list[PathHop] = []
for i, hop in enumerate(shown):
dim = dim_from is not None and i >= dim_from
if hop is None:
if bare_self:
# Where the route is being composed, both our ends are fixed — a walk
# leaves us and comes home to us, and neither end is anybody's to choose
# or remove — so the stars fade like every other automatic hop and the
# colour belongs to the nodes actually being picked. Where nothing is
# being composed (``dim_self=False``), that has nothing to say, and our
# own node keeps the app-wide ``you`` white.
built.append(PathHop(SELF_GLYPH, you=True, dim=dim_self or dim))
continue
note = _shorten(device_hash, hash_bytes) if (show_hash and device_hash) else None
built.append(
PathHop(self_name or LOCAL_DEVICE_LABEL, you=True, annotation=note, dim=dim)
)
continue
named = resolve(hop)
if named and named != hop:
built.append(
PathHop(
named,
key=hop,
you=bool(self_name and named == self_name),
annotation=_shorten(hop, hash_bytes) if show_hash else None,
dim=dim,
)
)
continue
compact = _shorten(hop, hash_bytes)
if dim: # a faded unnamed hop shows its compact hash, exactly as path_text does
built.append(PathHop(compact, dim=True))
elif hash_as_name:
identity = _shorten(hop, prefix_bytes or None)
note = compact if (show_hash and compact and compact != identity) else None
built.append(PathHop(identity, annotation=note))
else:
# No name resolved: the node is unknown, so its hash carries no key — the hop
# takes the app-wide unknown-node grey (arrow and chip alike), never a hue its
# bytes would derive. Colour marks an identified node, as in the path graph.
built.append(PathHop(compact))
if cursor is not None:
built.insert(max(0, min(cursor, len(built))), PathHop(CURSOR_GLYPH, cursor=True))
return PathLine(built, mode=mode, empty=empty)