Files
MeshTerm/meshterm/ui/message_paths_screen.py
T
Jean-Pierre MartineauandClaude Opus 5.5 abfd32c491 Docstrings and comments of meshterm/ui are in ASD-STE100
The same rewrite as for the core package, for the screens, the dialogs,
the map, the charts, and the CLI renderers. The glossary words are used
for the screen: the user (not the reader), dialog (not popup), viewport,
list window, highlight, pointer, graph lane, marker, raster, result
screen, and key prefix (never hash for a truncated key).

The rewrite also corrects approximately 70 docstrings and comments that
no longer agreed with the code: rows that no longer exist (Back rows),
counts that changed, renamed constants, wrong cross-references, and
behaviour that changed. Each correction follows the code; no code
changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 20:37:26 -04:00

635 lines
32 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.
# SPDX-License-Identifier: Apache-2.0
"""The Message paths dialog: all the paths on which one chat message reached our node.
This dialog is the interactive part of :mod:`~meshterm.services.message_paths`. The user
opens it from the chat with ``^P`` (or Enter on a selected direct message). The evidence
is one logged packet for each arrival, each with the relay path that it went through. The
dialog shows this evidence two times, so that the user can read the shape and the detail
together:
* A **graph** at the top draws each different path that the message went through. It
uses the shared route-graph widget (:mod:`~meshterm.ui.pathgraph`). The selected path
is white, and the unused paths are grey below it. The origin is at the left, and our
node is at the right. Each relay between them gets its own node-type marker (``▲``
repeater, …) and the first byte of its hash. The byte is directly above or below the
marker, in the colour of the node name. Thus the byte reads as that node, and it stays
apart from the line that goes through the node. A legend of the node types is below
the graph. The rows of the list have the full names. Thus the labels of the graph stay
two cells wide, and a graph with many paths stays readable.
* The **arrival list** below the graph has two lines for each logged copy. The first line
has only the **route**, as a path line (:mod:`~meshterm.ui.pathline`), with no label
before it. The route has the origin, each relay, and our node. These are the same two
ends that the graph draws between, so the row and the picture start and end at the
same places. Each node is a chip in its own hue. The chip shows the name of the node
when MeshTerm knows it (``Lakeside``). If not, the chip shows the hash of the node at
the path-hash width of the device (``e839f2``, in the grey of a node with no key). The
end at our node is the ``★`` of the whole app. No hash comes after a name, because the
hash bytes are in the graph above. Also, a chip and its label have the colour of the
node. Thus the two parts refer to each other by hue, and the hex is not written two
times. The second line, muted and indented under the route, has the facts of the
packet: the time when it was heard, the SNR of the reception, and the number of the
resend.
The route is the line that the user selects, because the route is the difference
between one arrival and another, and the graph shows that route in white. The list
scrolls in the dialog, in the rows that the quote and the graph above it leave. Faint
``↑ n more`` and ``↓ n more`` markers are at its edges (the list window of the whole
app, :class:`~meshterm.ui.tui.screen.ListWindow`). Thus, when the user moves through
the arrivals, the picture that they belong to never goes out of the box. Also, the
graph compresses its graph lanes before the list loses its list window. The ↑ and ↓
keys move the highlight, and the graph follows it. PgUp and PgDn move the highlight by
one list window. A route that is longer than the dialog **scrolls horizontally with
←→**. The full line moves, with a cracked chip at each edge where the route continues.
When the highlight moves to a different row, the route goes back to its start
immediately. A row that is not highlighted is cut the same way, but it cannot move.
The **scope** of the message (the region that the message was flooded into) is a status
atom in the title (``Message paths · scope harbour``). The title states it one time for
the message, not for each arrival, because each copy carries the transport code of its
sender unchanged (refer to :func:`~meshterm.services.message_paths.message_scope`).
Nothing in this module transmits. Like the service below it, this dialog is a read-model
over the packets that the radio already heard.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from rich.text import Text
from ..core.models import ChatMessage
from ..services.message_paths import Arrival
from .pathgraph import PathLayer, render_path_graph, revisited_hops
from .pathline import (
ELIDE_HEAD,
ELIDE_TAIL,
SELF_GLYPH,
PathHop,
PathLine,
cut_mark,
cut_to,
hops_atom,
path_line,
)
from .theme import snr_style
from .tui.render import crop_cells, render_lines, render_to_ansi
from .tui.screen import ListWindow, Screen
from .widgets import (
NameKeyResolver,
NodeResolver,
TypeOf,
node_type_legend,
revisit_note,
route_graph_style,
scope_text,
short_frame,
)
if TYPE_CHECKING:
from ..core.regions import Scope
#: The number of cells by which one ← or → key press moves the selected row.
_HSTEP = 4
#: The indent in cells of the line of reception facts, under the route that it is part
#: of. The indent is past the ``❯ `` pointer lane, so that the two lines read as one
#: arrival with its detail under it.
_DETAIL_INDENT = 4
#: The minimum number of rows that the graph can compress to, and the maximum number of
#: rows that it can use. When rows are not sufficient, the graph makes the pitch of its
#: graph lanes smaller first, so that the arrival list keeps its list window.
_GRAPH_MIN_ROWS = 5
_GRAPH_MAX_ROWS = 15
#: The number of lines that the arrival list always gets from the budget of the dialog.
#: Thus a tall graph can never make the list smaller than two arrivals and the marker
#: that says that more are hidden. One row alone is only a fact, but the purpose of this
#: dialog is to compare one arrival with another.
_LIST_MIN_LINES = 5
#: The colours of the edges: the selected path is white, over the grey of the unused paths.
_EDGE_SELECTED = (255, 255, 255)
_EDGE_UNUSED = (110, 110, 110)
class MessagePathsScreen(Screen):
"""A floating dialog: the arrivals of one message as a path graph above a list of rows."""
def __init__(
self,
message: ChatMessage,
arrivals: list[Arrival],
*,
matched: bool,
resolve: NodeResolver,
prefix_bytes: int,
self_name: str | None,
summary: str,
source: str | None = None,
destination: str | None = None,
type_of: TypeOf | None = None,
key_of: NameKeyResolver | None = None,
scope: Scope | None = None,
sent_scope: Text | None = None,
) -> None:
"""Build the dialog for the matched arrivals of one message.
Args:
message: The chat message whose evidence of delivery the dialog shows.
arrivals: The logged arrivals of the message, oldest first. The list can be
empty.
matched: Whether the arrivals were matched by content (a decrypted channel
packet), and not only by time. When they were not, the summary line
shows a warning.
resolve: Gives the friendly name for the hash of a hop, when the name is
known.
prefix_bytes: The path-hash width of the device: the number of bytes of the
hash of a hop with no name that the dialog shows instead of its unknown
name.
self_name: The name of our node (the right end of the graph, in white).
summary: The one-line summary of the evidence, shown under the quoted text.
source: The display name of the origin of the message: the sender parsed
from a channel message, our node for an outbound message, or the peer
for a direct chat. ``None`` shows as an unknown origin, ``?``.
destination: The display name of the node that the message was addressed
to: the far end for a message that our node sent, or our node for a
message that it received. This node ends the path line. Thus an
outgoing message reads ``★ ▶ … ▶ Bob``, and it does not start and end
on our own star. Before, it did, and each send looked like a round trip
(JP, 2026-09-02). ``None`` ends the line on our own ``★``.
type_of: Gives the node type for the hash of a relay. Thus the graph marks
a repeater with ``▲`` (and the other types with their markers), not with
a generic dot. ``None`` keeps the plain dots.
key_of: Gives the key of the node for the display name of the origin. Thus
the left end of the graph gets the hue that comes from the key. ``None``
(or a name that it cannot resolve) leaves that end muted.
scope: The region that the message was flooded into, read from the packets
of its arrivals (:func:`~meshterm.services.message_paths.message_scope`).
It is ``None`` when no copy was a flood. Then the title states no scope.
sent_scope: For a message that our node sent: the line that says the scope
that the message was flooded under (``sent under scope yul``), and why
no node relayed it, when that is known. The dialog draws this line under
the summary. ``None`` draws nothing.
"""
super().__init__()
self.title = self.titled(scope)
self._message = message
self._arrivals = arrivals
self._matched = matched
self._resolve = resolve
self._prefix_bytes = prefix_bytes
self._self_name = self_name
self._summary = summary
self._source = source
self._destination = destination
self._type_of = type_of
self._key_of = key_of
self._sent_scope = sent_scope
self._index = 0
#: The number of cells by which the selected row is moved left (set to zero each
#: time ↑ or ↓ moves the highlight), and the maximum move, measured against the
#: width of the last paint.
self._hshift = 0
self._hmax = 0
self._cursor: int | None = None
#: The list window of the arrival list, over the rows that the pinned head leaves
#: (its ``page`` is the step of PgUp and PgDn). Also, whether the list window hides
#: rows now.
#: The hint shows the paging keys only when it does, because only then do these
#: keys do something.
self._list = ListWindow()
self._list_hidden = False
@staticmethod
def titled(scope: Scope | None) -> str:
"""The title of the dialog: its name, then the scope of the message as a status atom.
Each copy of one message carries the transport code of its sender. (The code is
calculated over the payload, and no relay changes the payload.) Thus the scope is a
fact about the message, not about one arrival. The title states it one time, and
the list does not repeat it on rows that are different only by path. The scope is
in the title, because the status atoms of a screen make a chain in the title
(``Message paths · scope harbour``). Also, there it uses no line of the body. On
the 26 rows of the PicoCalc, a line here is one graph lane or one row of the
list. The words come from :func:`~meshterm.ui.widgets.scope_text`, without style,
because a title has no styles. Thus ``unscoped`` and ``unknown scope 3fa1`` read
here the same as on the ``route`` row of the packet viewer.
"""
atom = scope_text(scope).plain
return f"Message paths · {atom}" if atom else "Message paths"
# --- input ---------------------------------------------------------------
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The keys for the rows while there are rows. If there are no rows, only the close hint.
The paging atom shows only while the list window hides arrivals. This is the rule of
the whole app: a hint never shows a key that does nothing.
"""
if not self._arrivals:
return "Esc close"
parts = ["↑↓ move"]
if self._list_hidden:
parts.append("PgUp/PgDn scroll")
parts.extend(("←→ scroll line", "Esc close"))
return " · ".join(parts)
@property
def picocalc_lyra_lane(self):
"""The shared pager, dim when there is only one arrival or none.
Here, both banks move the highlight. The pager moves it by one list window, and
the jumps move it to the first and the last arrival. Thus the chips are live
exactly when there is more than one arrival to move between, also when the list
window hides no arrivals.
"""
from .tui.fkeys import default_lane
return default_lane(nav=len(self._arrivals) > 1)
def handle(self, action: str, data: str = "") -> None:
"""Move the highlight, move the selected line to the side, or close the dialog."""
rows = len(self._arrivals)
# Both ends clamp. The highlight does not go around to the other end, because the
# arrivals scroll in a list window (refer to ListWindow). If the highlight jumps
# from the last arrival to the first, the list window goes with it. That is the only
# move that looks like a change of the whole screen.
if action == "up" and rows:
self._index = max(0, self._index - 1)
self._hshift = 0
elif action == "down" and rows:
self._index = min(rows - 1, self._index + 1)
self._hshift = 0
elif action == "pageup" and rows:
# One list window of arrivals, not of body lines, because the list is what moves.
self._index = max(0, self._index - self._list.page)
self._hshift = 0
elif action in ("pagedown", "space") and rows:
self._index = min(rows - 1, self._index + self._list.page)
self._hshift = 0
elif action in ("home", "ctrl_home") and rows:
self._index = 0
self._hshift = 0
elif action in ("end", "ctrl_end") and rows:
self._index = rows - 1
self._hshift = 0
elif action == "left":
self._hshift = max(0, self._hshift - _HSTEP)
elif action == "right":
self._hshift = min(self._hmax, self._hshift + _HSTEP)
elif action in ("escape", "enter"):
self.resolve(None)
def cursor_line(self) -> int | None:
"""The body line of the selected row.
The list window already keeps the row in the dialog. Thus this value has an effect only
on a terminal that is too short for the compressed head.
"""
return self._cursor
# --- rendering -------------------------------------------------------------
def render_body(self, width: int) -> list[str]:
"""The pinned quote and path graph, then the arrival list in a list window below them.
The head does not move. It is the quoted message, the summary of the evidence, and
the graph with its captions. Only the arrival rows scroll (refer to
:class:`~meshterm.ui.tui.screen.ListWindow`). Thus, when the user moves through the
arrivals, the picture that they are compared with never goes out of the dialog.
The three parts share one budget. The head gets its lines, and the list always gets
:data:`_LIST_MIN_LINES`. The graph compresses the pitch of its graph lanes into the
rows that remain, and it does not grow past them.
"""
viewport = self._scroll_viewport
quoted = self._message.text.replace("\n", " ")
if len(quoted) > 64:
quoted = quoted[:63] + "…"
stamp = Text(self._message.created_at.astimezone().strftime("%b %d %H:%M"), style="muted")
stamp.append(" · ", style="muted")
stamp.append(self._summary, style="muted" if self._matched else "warn")
lines = [
render_to_ansi(Text(f"“{quoted}”"), width, no_wrap=True),
render_to_ansi(stamp, width, no_wrap=True),
]
if self._sent_scope is not None:
# Wrapped, not cut, because its end is the reason why a message went nowhere.
lines.extend(render_lines(self._sent_scope, width))
self._cursor = None
self._list_hidden = False
if not self._arrivals:
lines.append("")
note = (
"Nothing overheard — the radio only logs frames it hears while "
"MeshTerm is listening."
if self._matched
else "No direct-message frames logged in the window."
)
# One entry for each drawn row. A wrapped render_to_ansi is one string that
# holds a newline, and the frame counts it and measures it as one line.
lines.extend(render_lines(Text(note, style="muted"), width))
self._scroll_total = len(lines)
return lines
# The rows are drawn before the graph gets its size, because the lines that the
# rows need are one of the terms that MeshTerm subtracts to find the row limit of
# the graph.
blocks = [
self._row_block(arrival, i == self._index, width)
for i, arrival in enumerate(self._arrivals)
]
heights = [len(block) for block in blocks]
# No blank line above the graph. Its canvas already starts with empty space above
# the top graph lane, so a blank line here reads as two rows of margin.
revisit = self._revisit_line(width)
# The caption and the node-type legend of the graph. A short frame leaves them out,
# because the markers have one meaning in the whole app, and the rows go to the
# list (refer to widgets.short_frame).
key: list[str] = []
if not short_frame(viewport):
far = "you" if not self._destination else self._destination
caption = Text(
f"origin → {far} · white = selected path · labels = hash byte",
style="faint",
)
key.append(render_to_ansi(caption, width, no_wrap=True))
key.extend(render_lines(node_type_legend(width=width), width, no_wrap=True))
# The chrome after the graph (that legend and the revisit note), and the blank line
# that puts the list apart. MeshTerm subtracts all of them from the budget before it
# calculates the row limit.
chrome = len(key) + len(revisit) + 1
room = viewport - len(lines) - chrome - min(sum(heights), _LIST_MIN_LINES)
graph = self._graph_lines(width, max(_GRAPH_MIN_ROWS, min(_GRAPH_MAX_ROWS, room)))
if graph:
lines.extend(graph)
lines.extend(key)
lines.extend(revisit)
lines.append("")
# -- the arrival list, in a list window in the rows that the head left.
window = max(min(heights), viewport - len(lines))
top, count = self._list.fit_blocks(heights, window, self._index)
if top > 0:
lines.append(render_to_ansi(ListWindow.marker(top, "above"), width))
for i in range(top, top + count):
if i == self._index:
self._cursor = len(lines)
lines.extend(blocks[i])
below = len(blocks) - top - count
if below > 0:
lines.append(render_to_ansi(ListWindow.marker(below, "below"), width))
self._list_hidden = top > 0 or below > 0
self._scroll_total = len(lines)
return lines
def _row_block(self, arrival: Arrival, selected: bool, width: int) -> list[str]:
"""One arrival as the list window moves it: its route, then its facts indented under it."""
if selected:
route = self._selected_line(arrival, width)
else:
row = Text(" ")
row.append_text(self._path_text(arrival))
# Cut the route here, and do not let the render boundary ellipsize it. An
# unselected route goes past the end of the lane the same as the selected
# route, and it must show the user the same cracked chip, not three dots.
route = render_to_ansi(cut_to(row, width), width, no_wrap=True)
detail = Text(" " * _DETAIL_INDENT)
detail.append_text(self._detail_text(arrival))
return [route, render_to_ansi(detail, width, no_wrap=True)]
# -- the rows --
def _path_text(self, arrival: Arrival) -> Text:
"""The full route of one arrival: the first line of the row, which the user selects.
The route has the full width of the row, so no ``via`` comes before it. On a lane
that holds nothing else, there is nothing to tell it apart from. It is the only
path line of the app, so a route here is drawn the same as a route at all other
places: chips in the hue of each node where the terminal can draw them, and arrows
where it cannot. The hops show only names. A hop with no name to show shows its
own hash at the path-hash width of the device instead (in muted grey, because
colour is the signal for "this is a name"). No hop repeats its hash after its
name. The hash bytes are in the graph, one row up. The node hue that the two share
connects them, not a second copy of the hex.
The line goes **from the origin to our node**, not from relay to relay (JP,
2026-08-09). The ends of a path are the nodes that it went between. A chain that
starts on its first relay reads as a route from a node that only passed the
message on. Thus the sender is first (:meth:`_origin_hop`), and the line ends on
the ``★`` of the whole app. The graph one row up draws between these same two
ends. Now the two name the same two ends, and the row does not start one hop
after the picture above it. An arrival with no hops is not a single word now:
``Alice ▶ ★`` is how direct delivery looks, and it has the same form as all the
other rows.
"""
relays = path_line(
arrival.hops,
self._resolve,
prefix_bytes=self._prefix_bytes,
self_name=self._self_name,
hash_as_name=True,
).hops
return PathLine([self._origin_hop(), *relays, self._far_hop()]).text()
def _far_hop(self) -> PathHop:
"""The node at the end of the path: the addressee of the message, not always our node.
Before, the line always ended on the ``★`` of our node. That is correct for a
message that our node received, and wrong for a message that our node sent. Our
star also starts the line (:meth:`_origin_hop`), so each outgoing message drew
``★ ▶ … ▶ ★`` and read as a round trip. This is the same rule that the line already
follows at its start (a path goes between the nodes that it went between), now
also at its end.
"""
if not self._destination:
return PathHop(SELF_GLYPH, you=True)
if self._self_name and self._destination == self._self_name:
return PathHop(SELF_GLYPH, you=True)
return PathHop(
self._destination,
key=self._key_of(self._destination) if self._key_of else None,
)
def _origin_hop(self) -> PathHop:
"""The node that the message started from: the true start of the route.
It is the same origin that the left end of the graph draws
(:func:`~meshterm.ui.widgets.route_graph_style`), with the same name and hue. The
display name of the sender is in the hue that comes from its key. A message that
our node sent shows our own ``★``. When the packet named no node, the hop is only
``?``. MeshTerm never guesses a hue from a name that it cannot place.
"""
if not self._source:
return PathHop("?")
if self._self_name and self._source == self._self_name:
return PathHop(SELF_GLYPH, you=True)
return PathHop(self._source, key=self._key_of(self._source) if self._key_of else None)
def _detail_text(self, arrival: Arrival) -> Text:
"""The reception facts of one arrival: when it arrived, how strong it was, which copy.
This is the second line of the row, muted and indented under the route that it
describes. The path is the difference between one arrival and another (and the
graph above draws it), so the path gets the lane. The details of the packet go
below it.
The hop count is first (:func:`~meshterm.ui.pathline.hops_atom`). It is the one
fact about the route that the line above encodes but does not state. Also, it is
the first number by which the user compares two arrivals of the same message.
A row is one path, not one packet (refer to
:func:`~meshterm.services.message_paths.collapse`). Thus a path that was heard more
than one time shows how many times, and the time is the time of the first of them.
The reason is that a direct send is tried again, and the device overhears each try
from each repeater in range. Before, this filled the list with a dozen lines that
were the same. The SNR is the best SNR of that path, because that shows what the
path can do.
"""
# The meaning of the hop count depends on the route type, so the lane says which
# type. The path of a flooded packet is where it went. The path of a direct-routed
# packet is where it was going to go. A routed packet that carries no path had its
# route consumed. That is not the same as an arrival in zero hops, although the
# empty field looks the same.
if not arrival.route_known:
text = Text("routed", style="warn")
text.append(" · route not carried", style="muted")
else:
text = hops_atom(len(arrival.hops))
if arrival.routed:
text.append(" routed", style="muted")
text.append(" ")
text.append(arrival.when.astimezone().strftime("%H:%M:%S"), style="muted")
if arrival.copies > 1:
text.append(f" ×{arrival.copies}", style="muted")
if arrival.snr is not None:
text.append(" ")
text.append(f"{arrival.snr:+.1f} dB", style=snr_style(arrival.snr))
if arrival.resend:
text.append(f" · resend #{arrival.resend}", style="muted")
return text
def _selected_line(self, arrival: Arrival, width: int) -> str:
"""The highlighted path: the ``❯`` pointer, and the route moved by ``←→`` under cut marks.
The selected line is the route. It is the only thing here that can be long enough
to scroll, and it is the path that the graph above shows in white. The limit of the
move is measured here against the current width. Thus, after a resize, the row can
only be clamped back into its range.
Each edge past which the route continues gets
:func:`~meshterm.ui.pathline.cut_mark`. Thus a chip that the scroll cuts breaks off
on a half block in its own colour, not behind an ellipsis. The route moves to the
side, but it is not made shorter, and the cracked segment shows this. A route drawn
with arrows uses the ``…`` that the row always showed.
"""
full = self._path_text(arrival)
avail = max(1, width - 2)
self._hmax = max(0, full.cell_len - avail)
self._hshift = min(self._hshift, self._hmax)
shift = self._hshift
left_more = 1 if shift > 0 else 0
right_more = 1 if shift + avail < full.cell_len else 0
inner = max(1, avail - left_more - right_more)
line = Text("❯ ", style="cursor", no_wrap=True)
# Each mark stands for the cell immediately outside the visible part on its side. Thus
# each mark takes its colour from the nearest cell that is still drawn, because the
# chip that the user can see is the chip that visibly continues.
if left_more:
line.append_text(cut_mark(full, shift + left_more, ELIDE_HEAD))
line.append_text(crop_cells(full, shift + left_more, inner))
if right_more:
line.append_text(cut_mark(full, shift + left_more + inner - 1, ELIDE_TAIL))
line.style = "cursor"
return render_to_ansi(line, width, no_wrap=True)
# -- the graph --
def _paths(self) -> list[tuple[str, ...]]:
"""The different relay paths of the arrivals, in the order that they were first heard."""
seen: list[tuple[str, ...]] = []
for arrival in self._arrivals:
# A packet whose route was consumed has nothing to draw. An empty graph lane says
# that the two ends are next to each other, but the packet never said that.
if arrival.route_known and arrival.hops not in seen:
seen.append(arrival.hops)
return seen
def _graph_lines(self, width: int, max_rows: int) -> list[str]:
"""Draw each different path from the origin to our node, the selected path white over grey.
The shared route-graph widget does the layout: a flow of paths from left to right
that go apart and come together, with each route on its own graph lane and each
relay drawn one time. This method only maps each different path to a
:class:`~meshterm.ui.pathgraph.PathLayer` (the selected path white on top, the
other paths grey below it). Then it gives the widget the shared route-graph
callbacks (:func:`~meshterm.ui.widgets.route_graph_style`). These callbacks name
the ends, give each relay its own map marker when the type is known, and label each
relay with the first byte of its hash in the hue of the node.
The order in which the paths were first heard sets the layout rank (the first path
heard is always the spine). The Routes tab of the node detail sets the rank by the
order of the evidence in the same way. Thus the shape of the graph does not move
while ↑ and ↓ move through the arrivals. Only the emphasis (which graph lane is
white and on top) follows the selection.
Args:
width: The width of the canvas in cells.
max_rows: The maximum number of rows for the graph. This is what the budget of
the dialog leaves after the quote, the captions of the graph, and the
minimum lines of the list. A graph with more graph lanes than that
compresses the pitch of its graph lanes to fit.
"""
selected = self._arrivals[self._index].hops
paths = self._paths()
if not paths:
return []
layers = [
PathLayer(
hops=path,
color=_EDGE_SELECTED if path == selected else _EDGE_UNUSED,
priority=len(paths) - i,
emphasis=1 if path == selected else 0,
)
for i, path in enumerate(paths)
]
glyph_of, label_of, label_rgb_of = route_graph_style(
resolve=self._resolve,
self_name=self._self_name,
source=self._source,
destination=self._destination,
type_of=self._type_of,
key_of=self._key_of,
)
return render_path_graph(
layers,
width,
max_rows=max_rows,
glyph_of=glyph_of,
label_of=label_of,
label_rgb_of=label_rgb_of,
allow_duplicate_nodes=True,
)
def _revisit_line(self, width: int) -> list[str]:
"""The ⚠ note when a drawn path goes through one hop two times. If not, no line.
These paths were overheard, not composed. Thus a repeated hop is real evidence, and
the graph draws a marker for each visit (``allow_duplicate_nodes``). It does not fold
the walk into a cycle that it cannot place. The result is one name on two markers,
and this note explains it. The note is one time for the whole graph, and it names
each hop that is repeated at any place in the graph. The reason is that the note is
about the picture, not about the arrival that ↑ and ↓ selected at that time.
"""
repeated: list[str] = []
for path in self._paths():
for hop in revisited_hops(path):
if hop not in repeated:
repeated.append(hop)
note = revisit_note(repeated, self._resolve, self_name=self._self_name)
return [] if note is None else [render_to_ansi(note, width, no_wrap=True)]