Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1249 lines
64 KiB
Python
1249 lines
64 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
"""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 — or on two
|
||
fills too close to tell apart (under :data:`SEAM_BLUR` apart in OKLab, the perceptual
|
||
distance :mod:`~meshterm.ui.oklab` measures) take the one exception the interlock can't
|
||
draw: the seam is the *thin* chevron instead, the previous fill shaded a step darker
|
||
(lighter, for a dark fill) and drawn on the next, so the ribbon runs on unbroken and the
|
||
join is a line the chip draws on itself (: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 line's two *outer* ends read the same way:
|
||
a path that begins at its origin and ends at its destination opens and closes flat (a
|
||
rounded cap where the font has one), while one drawing only the *middle* of a route —
|
||
a packet's ``via`` chain, which names relays and neither of the nodes the packet went
|
||
between — wears the chevron at that end instead, the same mark a wrapped line uses for
|
||
"there is more of this out there" (``from_origin`` / ``to_destination``).
|
||
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 . import oklab
|
||
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 glyph the widget draws
|
||
#: at every seam and closing edge, 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 thin right-pointing chevron (U+E0B1), the solid point's outline sibling in the
|
||
#: same core set — powerline's own mark for a join *inside* one colour. It draws the seam
|
||
#: between two chips whose fills the eye can't tell apart (:meth:`PathLine._seam`): the
|
||
#: previous fill, shaded, drawn on the next, where a solid point would have vanished.
|
||
POWERLINE_THIN = "\ue0b1"
|
||
|
||
#: 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
|
||
|
||
|
||
#: How far apart two chip fills must sit, as the eye measures it, for the seam between
|
||
#: them to interlock: closer than this and the solid point would be laid on a field it
|
||
#: can't be told from, so the seam is the thin chevron instead (:meth:`PathLine._seam`).
|
||
#: The unit is OKLab distance (:func:`~meshterm.ui.oklab.distance`), where ``0.02`` is a
|
||
#: just-noticeable difference between two large patches and ``1`` is black to white.
|
||
#:
|
||
#: The rule it replaced was first key bytes under ``0x10`` apart on the hue wheel (JP,
|
||
#: 2026-09-15), which measures ``0.087`` at the median hue but ``0.026`` across the greens
|
||
#: and ``0.165`` across the cyans: the wheel is not uniform to the eye, so a hue gap
|
||
#: blurred too little there and too much here, and an sRGB distance would only have
|
||
#: restated the hue gap (a fixed byte step is a near-constant ``55``–``60`` of 8-bit RGB
|
||
#: all the way round). The value was then set by eye on a ladder of the spectrum's own
|
||
#: pairs drawn both ways (JP, 2026-09-16), and it sits where the solid point is not just
|
||
#: detectable at one cell but *found* without looking for it: ``0.06``, three noticeable
|
||
#: differences. Tighter (``0.035``) left wedges a reader had to hunt for; looser
|
||
#: (``0.10``) blurred pairs whose point still read plainly; ``0.05`` was a shade too
|
||
#: tight. ``0x10`` of green is ``0.026`` and blurs; ``0x10`` of red is ``0.124`` and
|
||
#: doesn't; the two greys, ``0.18`` apart, keep their interlock on the metric alone.
|
||
SEAM_BLUR = 0.06
|
||
|
||
#: How far the thin chevron's colour steps from the fill it is drawn in, in OKLab ``L``
|
||
#: — a lightness shift alone, so it reads as *that chip's colour, shaded* rather than as
|
||
#: a third colour on the line. The step is taken from the previous fill but measured from
|
||
#: whichever of the two fills is further along (:func:`~meshterm.ui.oklab.shaded`'s
|
||
#: ``floor``), so the mark clears the fill it actually sits on by this much even when the
|
||
#: two differ by everything :data:`SEAM_BLUR` allows. A lightness step is what a thin line
|
||
#: is seen by (the eye resolves detail in luminance, not in chroma), so it needs nothing
|
||
#: like the blur distance to read: ``0.10`` is five noticeable differences, a clear line
|
||
#: that is still plainly the chip's own colour, chosen on the same ladder (JP, 2026-09-16).
|
||
SEAM_SHADE = 0.10
|
||
|
||
|
||
def _fills_blur(before: str, after: str) -> bool:
|
||
"""Whether two fills are the same to the eye: identical, or under :data:`SEAM_BLUR`."""
|
||
return before == after or oklab.distance(before, after) < SEAM_BLUR
|
||
|
||
|
||
def _seam_ink(before: str, after: str) -> str:
|
||
"""The thin chevron's colour: ``before`` shaded :data:`SEAM_SHADE` past both fills."""
|
||
return oklab.shaded(before, SEAM_SHADE, floor=oklab.from_hex(after)[0])
|
||
|
||
|
||
#: 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"``).
|
||
from_origin: The first hop *is* the route's origin. Clear it where the line
|
||
draws only the middle of a route — a packet's ``via`` chain names its
|
||
relays and neither the node that sent it nor the one that received it — and
|
||
the head is drawn as a continuation instead of as a beginning.
|
||
to_destination: The last hop *is* the route's destination. Clear it under the
|
||
same reading at the other end.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
hops: Sequence[PathHop],
|
||
*,
|
||
mode: str = "auto",
|
||
separator: str = _ARROW,
|
||
empty: str = "direct",
|
||
from_origin: bool = True,
|
||
to_destination: bool = True,
|
||
) -> 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
|
||
self._from_origin = from_origin
|
||
self._to_destination = to_destination
|
||
|
||
@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), ends=False)
|
||
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, _foot = 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, 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, foot)`` — 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 the path *runs on past* closes with (chip mode's
|
||
point; nothing in arrow mode), the cells every line *after the first* opens
|
||
with (chip mode's notch, the seam redrawn), and the two outer caps: what the
|
||
*first* line opens with and what the line that *ends* the path closes with —
|
||
one cell each where the font has the rounded caps, nothing where it hasn't,
|
||
a squared end being drawn by appending nothing at all.
|
||
"""
|
||
if plain:
|
||
plain_cells = [self._plain_hop(hop).cell_len for hop in hops]
|
||
head = 0 if self._from_origin else cell_len(self._separator.lstrip())
|
||
foot = 0 if self._to_destination else cell_len(self._separator.rstrip())
|
||
return plain_cells, cell_len(self._separator), 0, 0, head, foot
|
||
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)
|
||
cap = cell_len(POWERLINE_ROUND_OPEN) if powerline_full() else 0
|
||
# An end the route runs on past costs its chevron whatever the font can draw:
|
||
# the point and the notch are core glyphs, and it is the *cap* that is optional.
|
||
# So a relays-only line is measured a cell wider at that end than a whole route
|
||
# is on a terminal with no rounded caps to spend.
|
||
head = cap if self._from_origin else sep
|
||
foot = cap if self._to_destination else sep
|
||
return widths, sep, sep, sep, head, foot
|
||
|
||
@staticmethod
|
||
def _fill(
|
||
cells: list[int],
|
||
join: int,
|
||
tail: int,
|
||
budget: int,
|
||
reserve: int,
|
||
last: int,
|
||
lead: int = 0,
|
||
head: int = 0,
|
||
foot: int = 0,
|
||
ends: bool = True,
|
||
) -> 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 a line the path runs on past 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).
|
||
foot: Cells the line that *ends the path* closes with (the rounded cap, when
|
||
drawn) — charged in place of ``tail``, a finished path closing on a cap
|
||
or, where the font has none, on nothing at all.
|
||
ends: These hops finish the path, so their last line pays ``foot``. ``False``
|
||
where they are one leg of a route that goes on and every line, the last
|
||
included, still closes on the point.
|
||
|
||
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):
|
||
final = i == len(cells) - 1
|
||
grown = width + cell + (join if count else 0)
|
||
held = last if final else reserve
|
||
close = foot if final and ends else tail
|
||
if count and grown + close + 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,
|
||
ends: bool = True,
|
||
) -> 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.
|
||
ends: These hops finish the path, so their last line closes on the outer cap
|
||
rather than on the continuation point (see :meth:`_fill`).
|
||
|
||
Returns:
|
||
The hops grouped per line.
|
||
"""
|
||
cells, join, tail, lead, head, foot = self._measure(hops, plain)
|
||
lead += WRAP_OFFSET # every continuation steps in past the hanging indent
|
||
if carry_in:
|
||
head = lead
|
||
|
||
def fill(column: int) -> list[int]:
|
||
return self._fill(cells, join, tail, column, reserve, last, lead, head, foot, ends)
|
||
|
||
lines = len(fill(budget))
|
||
low, high = 1, budget
|
||
while low < high: # the narrowest column still taking `lines` rows
|
||
mid = (low + high) // 2
|
||
if len(fill(mid)) <= lines:
|
||
high = mid
|
||
else:
|
||
low = mid + 1
|
||
groups: list[list[PathHop]] = []
|
||
at = 0
|
||
for size in fill(low):
|
||
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, carry_in=carry_in, carry_on=carry_on)
|
||
line.style = PATH_INK
|
||
return line
|
||
|
||
def _render_plain(
|
||
self, hops: list[PathHop], *, carry_in: bool = False, carry_on: bool = False
|
||
) -> Text:
|
||
"""Arrow-joined hops — ``path_text``'s presentation, hop by hop.
|
||
|
||
An end the route runs on past wears the separator with nothing on the far side
|
||
of it: a leading ``→`` where the origin isn't drawn, a trailing one where the
|
||
destination isn't. Arrow mode already spells "the path goes on" with that
|
||
trailing mark when a line wraps, so a chain of relays says it the same way —
|
||
which is what keeps the two modes telling the reader the same thing on a console
|
||
with no chevrons to shear (see :meth:`_render_chips`). The wrap flags win where
|
||
they overlap: a continuation already opens under its predecessor, and
|
||
:meth:`wrapped` appends the cue itself on a line the path outruns.
|
||
"""
|
||
text = Text()
|
||
if hops and not carry_in and not self._from_origin and not hops[0].gap:
|
||
text.append(self._separator.lstrip(), style="muted")
|
||
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))
|
||
if hops and not carry_on and not self._to_destination and not hops[-1].gap:
|
||
text.append(self._separator.rstrip(), style="muted")
|
||
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 the reader is looking at the whole route, and
|
||
two separate things can shorten it. **This line** may be one of several a wrapped
|
||
path folds into (``carry_in``/``carry_on``), and **the path itself** may only ever
|
||
have been the middle of a route — a packet's ``via`` chain names its relays and
|
||
neither of the nodes it actually went between (``from_origin``/``to_destination``
|
||
on the line). Either one draws the same mark at that end, because the reader is
|
||
being told the same thing: *the route goes on out there, past what is drawn*. A
|
||
head like that opens on the break's other half — the seam's own second cell, so a
|
||
fold looks like the seam it interrupted, and a relays-only chain looks like a
|
||
route caught mid-stride. A tail like that closes on the point, the same "goes on"
|
||
cue arrow mode spells with a trailing ``→``.
|
||
|
||
An end that really is the route's own opens or closes **rounded**, and, where the
|
||
font has no rounded caps, **square** — the chip's own pad is the edge, and nothing
|
||
is appended. Square is not a degraded cap, it is the one shape left that says
|
||
*stop*: the point is spoken for as the continuation cue, so closing a finished
|
||
path on one claimed a hop had been cut off (JP, 2026-09-09). Only the *opening*
|
||
used to square itself off, which left every route on a core-only terminal ending
|
||
on the mark for "there is more". The pair now reads both ways round — a square end
|
||
is a promise that the node beside it is where the route began or ended, which is
|
||
why a chain that starts and finishes on relays must not wear one (JP, 2026-09-09).
|
||
|
||
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()
|
||
# A fold and a half-drawn route are two reasons for the same mark; either is
|
||
# enough. An elision already breaks the ribbon on that side, and a mark cut out
|
||
# of a bare ``⋯`` would have no fill to cut into.
|
||
opens_mid = (carry_in or not self._from_origin) and not hops[0].gap
|
||
text = Text()
|
||
if opens_mid:
|
||
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
|
||
if carry_on or not self._to_destination:
|
||
text.append(POWERLINE_SEP, style=fills[-1]) # the point: the path goes on
|
||
elif rounded:
|
||
text.append(POWERLINE_ROUND_CLOSE, style=fills[-1]) # the lozenge's far end
|
||
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 the eye can't tell apart 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. Nor is "can't tell apart" the same as "identical":
|
||
two hues a few steps apart on the wheel draw a point the eye can't find either,
|
||
one shade of green on the next, so the test is a perceptual distance under
|
||
:data:`SEAM_BLUR` (:func:`_fills_blur`), not equality and not a hue gap.
|
||
|
||
There the seam is the **thin** chevron, :data:`POWERLINE_THIN`, in the previous
|
||
chip's own fill shaded a step darker — lighter, for a dark fill — and drawn on
|
||
the fill ahead (:func:`_seam_ink`, JP, 2026-09-16): powerline's own mark for a
|
||
join inside one colour, and the chip it belongs to drawing a line on itself, so
|
||
the ribbon runs on unbroken and nothing on it is a colour that isn't a chip's.
|
||
The solid point drawn bare used to cut a wedge of page out of the route here (a
|
||
run of near hues read as dashes, and on a light terminal the wedge was a hole),
|
||
and a chevron in the dark chip ink read as a mark laid on the route rather than a
|
||
seam in it. 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 _fills_blur(before, after):
|
||
return Text(POWERLINE_THIN, style=f"{_seam_ink(before, after)} on {after}")
|
||
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",
|
||
from_origin: bool = True,
|
||
to_destination: bool = True,
|
||
) -> 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"``).
|
||
from_origin: The first hop is the route's origin (see :class:`PathLine`). Clear
|
||
it where ``hops`` is a chain of *relays* — the raw ``path`` field a packet
|
||
carries names neither the node it came from nor the one it reached.
|
||
to_destination: The last hop is the route's destination, same reading.
|
||
|
||
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,
|
||
from_origin=from_origin,
|
||
to_destination=to_destination,
|
||
)
|