Files
MeshTerm/meshterm/ui/pathline.py
T
Jean-Pierre MartineauandClaude Fable 5 84caae53f8 P3 rework: the PicoCalc visual language, done properly
The first P3 (a84abf3) ran on the wrong model and its palette never
reached the TUI: MESH_THEME_16 was written in 256-colour cube indices
instead of the 16 VT slots, and the platform theme pick was wired into
the scripted-CLI console while the real rasterizer stayed hardcoded to
truecolor. Replace it end to end:

- theme._VT_SLOTS: the canonical slot->RGB design (conventional hue
  families kept; slots 5/6 become the track/faint slates), MESH_THEME_16
  rewritten against color(0..15) with the VT's bold-as-brightness and
  dim-bank-background rules pinned by tests. /etc/vtrgb + a boot oneshot
  install it (console-font script), OSC fallback when kbd is absent.
- platforms.on_platform: set_platform now re-runs registered bindings,
  so theme, rasterizer and widgets swap implementations once per switch
  and hot paths read module globals - no per-call get_platform().
- The render-boundary fold (theme.fold_text): one str.translate pass
  (NFKD accent folds - the Terminus base has no accented Latin - plus
  symbol stand-ins and the width-padded emoji map), an embedded-SGR
  quantizer for canvas truecolor, and a ?-at-own-width catch-all that
  makes the no-wide-glyph short-circuit true by construction. Applied at
  the only two places every visible byte passes: render_to_ansi and
  MapCanvas.to_ansi_lines.
- ui/fontset.py freezes the installed font's inventory (dumped live);
  the gallery now asserts rendered picocalc ANSI carries no truecolor/
  256-colour SGR and no character outside it - the parity gate the
  first attempt faked by asserting a dataclass field.
- Font build: nine new 6x12 marks (ellipsis, warn, backspace, shift,
  gear, refresh, clock, crosshair, padlock) drawn into donor slots, with
  donor misses now fatal and a keeper canary (donating pi once erased
  Cyrillic pe - shared slot). Table re-dumped on-device: identical to
  fontset.py, 519 = 519.
- Type-based name colouring: registry moved to core/nodetypes (services
  no longer import ui), fed from monitor observations, heard_nodes and
  merge_contacts. Heat gradient quantizes to heat.* steps on picocalc.
- ui/marks.py: the P2-deferred constant relocation - widgets no longer
  drags map_render/mapcanvas/pathgraph for tuples and aliases.
- meshterm specimen: the visual language on one card through the real
  funnels - the on-device acceptance screen (JP's request).
- tools/chat: delivered/no-ack state uses the standard check/query
  marks, not the banned emoji pair.

Verified on the Lyra: font+palette installed, specimen framebuffer
captures in .claude/plans/. 1259 tests green, 13 xfailed (P6 list).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:48:07 -04:00

825 lines
40 KiB
Python
Raw 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, seams drawn
as two solid triangles U+E0B0 — the previous chip tapering out to a point, the next
one notched inward out of its own fill, a sliver of bare page between them: the gapped
oh-my-posh look, where a route reads as a row of distinct blocks rather than one
fused ribbon. The chip fill is the node's hash-derived hue
(:func:`~meshterm.ui.theme.node_style`), our own node the pure ``you`` white, a
faded hop dark slate, a keyless hop grey. ``auto`` (the default) picks powerline exactly
when the terminal can draw it (:func:`~meshterm.ui.termfont.powerline_enabled` —
a recommended font, a glyph-capable renderer, or the user's override) and falls
back to arrows everywhere else, so no terminal ever sees tofu.
* **Three overflow answers**, matching the three patterns the surfaces already use:
:meth:`PathLine.text` is the full one-liner (for callers that ellipsize or
h-scroll it themselves), :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` for a breadcrumb
trail, which anchors on where the walk *is*) — 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
from dataclasses import dataclass
from typing import Callable, Optional, Sequence
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 .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 ``★`` — the same mark the map plants on us.
SELF_GLYPH = "★"
#: The composer's insertion cursor, standing in the route as a hop of its own: the
#: empty slot the next chosen hop drops into. A ``+`` because that is exactly what
#: Enter does there — and because no node is ever named one, so the slot can't be
#: misread as a hop that is already in the route.
CURSOR_GLYPH = "+"
#: The plain-mode joining arrow, exactly as ``path_text`` draws it today.
_ARROW = " → "
#: 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: the pure ``you`` white.
_YOU_BG = "#ffffff"
#: 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 = "⋯"
#: 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 — pure white, in either mode.
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 brand accent, the same colour the list
cursor (``❯``) below it wears, so the two halves of one gesture (the row
you pick, the slot it lands in) read as one thing.
"""
label: str
key: Optional[str] = None
you: bool = False
annotation: Optional[str] = None
lit_bytes: int = 0
dim: bool = False
style: Optional[str] = None
cursor: bool = False
def _style_hex(style: str) -> Optional[str]:
"""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
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:
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 truncated the classic way as the last resort.
* :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 = PathHop(_ELISION, dim=True)
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
last.truncate(width, overflow="ellipsis")
return last
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, truncated with an ellipsis.
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.truncate(room, overflow="ellipsis")
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 two: the point tapering out and
the notch cut into the next chip), 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's second half redrawn), and the
cells the *first* line opens with (chip mode's rounded cap, nothing where
the font has none).
"""
if plain:
return [self._plain_hop(hop).cell_len for hop in hops], cell_len(self._separator), 0, 0, 0
widths = [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 * 2, 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) -> Optional[int]:
"""The hop index a fold would read best at — where the route turns — or ``None``.
Two things mark a turn. A dimmed *tail* is one: the composed outbound leg ends
and the mirrored return begins (see :attr:`PathHop.dim`). 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: Optional[int] = len(self._hops)
while seam and self._hops[seam - 1].dim:
seam -= 1
if seam == len(self._hops): # nothing faded at the tail
seam = None
if seam is None or len(self._hops) - seam < 2:
marks = [(hop.label, hop.annotation) for hop in self._hops]
if len(marks) % 2 and marks == marks[::-1]:
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.
The two carry flags mark a wrapped line's ends and only mean anything to
chips; arrow mode says the same thing with the trailing cue.
"""
if not force_plain and self._resolved_mode() == "powerline":
return self._render_chips(hops, carry_in=carry_in, carry_on=carry_on)
return self._render_plain(hops)
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 accent, so the slot takes the app's reverse-video
# `selected` — the same block the editor's cursor has always been.
text.append(hop.label, style="selected")
elif hop.dim:
text.append(hop.label, style="faint")
elif hop.style:
text.append(hop.label, style=hop.style)
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:
text.append(hop.label, style="muted")
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 ``→``.
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:
text.append(POWERLINE_ROUND_OPEN, style=fills[0])
for i, hop in enumerate(hops):
if i:
text.append_text(self._seam(fills[i - 1], fills[i]))
text.append_text(self._chip(hop, fills[i]))
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
@classmethod
def _seam(cls, before: str, after: str) -> Text:
"""The two cells between two chips: a point tapering out, a notch cut back in.
The previous chip's point carries *no* background, so the page shows through the
wedge it tapers into; the next chip's left edge then opens on the same point in
the page's own colour, notched inward out of its fill. Both faces angle the way
the path flows, and between them sits a sliver of bare page — the small gap
oh-my-posh leaves between its segments.
The alternative (one cell, the previous fill interlocked *on* the next) packs
the row tighter but fuses the route into a continuous ribbon, and two neighbours
that happen to land on the same fill — a hue collision, a run of faded hops, two
keyless greys — then read as a single block where the route has two nodes. One
gap, every seam: a chip is always a chip.
Args:
before: The fill the seam tapers out of (the previous chip's).
after: The fill the seam notches back into (the next chip's).
Returns:
The seam's two cells, styled.
"""
text = Text()
text.append(POWERLINE_SEP, style=before)
text.append_text(cls._notch(after))
return text
@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, white you, hue, grey.
The insertion slot outranks everything — it is the one chip that isn't a node,
and it wears the brand accent so it reads as chrome among identities rather than
as a hop with an unlucky hue. The fade comes next, outranking identity including
our own. A dimmed hop is one nobody composed (an automatic landing back home, a
mirrored return leg), and the white ``you`` chip is the loudest thing on the
line: our automatic end must recede with the rest of the automatic half, not
shout over the hops that *are* news. Plain mode says the same thing by fading
the name.
"""
if hop.cursor:
return _style_hex("brand") or _KEYLESS_BG
if hop.style:
resolved = _style_hex(hop.style)
if resolved:
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."""
ink = _DIM_FG if hop.dim 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: Optional[int]) -> 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[Optional[str]],
resolve: Callable[[str], Optional[str]] = lambda hop: hop,
*,
prefix_bytes: int = 0,
self_name: Optional[str] = None,
empty: str = "direct",
show_hash: bool = False,
hash_bytes: Optional[int] = None,
device_hash: Optional[str] = None,
dim_from: Optional[int] = None,
hash_as_name: bool = False,
bare_self: bool = False,
dim_self: bool = True,
cursor: Optional[int] = 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
prefix-lit hash (or its muted identity hash under ``hash_as_name``), ``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 to light in unnamed hops' hashes (0 = none).
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, key=hop, 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:
built.append(PathHop(compact, key=hop, lit_bytes=prefix_bytes))
if cursor is not None:
built.insert(max(0, min(cursor, len(built))), PathHop(CURSOR_GLYPH, cursor=True))
return PathLine(built, mode=mode, empty=empty)