diff --git a/meshterm/ui/message_paths_screen.py b/meshterm/ui/message_paths_screen.py index 2cffc13..2197c2c 100644 --- a/meshterm/ui/message_paths_screen.py +++ b/meshterm/ui/message_paths_screen.py @@ -14,10 +14,12 @@ detail read together: through it. A node-type key sits under the graph. The row list carries the full names, so the graph's labels stay two cells wide and a many-path graph stays readable. * the **arrival list** beneath is one row per logged copy — time, reception SNR, and - the relay chain through the shared compact path widget, each named hop annotated - with the hash byte it is addressed by (``YUL-Poly (3d)``, the trace presentation); - an unknown relay stands in its own hash at the device's path-hash width, muted grey - and annotated with that byte (``e839f2 (e8)``). + the relay chain as a path line (:mod:`~meshterm.ui.pathline`): each relay a chip in + its own node hue, named where we know the node (``YUL-Poly``) and standing in its + own hash at the device's path-hash width where we don't (``e839f2``, keyless grey). + No hash is repeated after a name — the graph above is where the hash bytes live, and + a chip and its label share the node's colour, so the two halves cross-reference by + hue instead of by spelling the hex twice. ↑↓ move the selection (the graph's highlight follows); a row longer than the dialog **scrolls horizontally with ←→**, the whole line shifting under a ``…`` at whichever edge continues, and snaps back the moment the selection moves on. @@ -35,6 +37,7 @@ from rich.text import Text from ..core.models import ChatMessage from ..services.message_paths import Arrival from .pathgraph import PathLayer, render_path_graph +from .pathline import path_line from .theme import snr_style from .tui.render import crop_cells, render_to_ansi from .tui.screen import Screen @@ -43,7 +46,6 @@ from .widgets import ( NodeResolver, TypeOf, node_type_legend, - path_text, route_graph_style, ) @@ -183,7 +185,8 @@ class MessagePathsScreen(Screen): self._scroll_total = len(lines) return lines - lines.append("") + # No blank line above the graph: its canvas already opens with air over the + # topmost lane, so a spacer here would read as two rows of margin. lines.extend(self._graph_lines(width)) caption = Text("origin → you · white = selected path · labels = hash byte", style="faint") @@ -206,11 +209,13 @@ class MessagePathsScreen(Screen): def _row_text(self, arrival: Arrival) -> Text: """One arrival, unabridged: time, reception SNR, and its relay path. - Hops carry the trace presentation at the graph's grain: each named hop is - annotated with the hash byte it is addressed by (``YUL-Poly (3d)``); an unnamed - hop, having no name to show, stands in its own hash at the device's path-hash - width — muted grey, since colour is the "this is a name" signal — annotated with - that same byte (``e839f2 (e8)``), so the rows and the graph's labels cross-reference. + The relay chain is THE path line, so a route here is drawn exactly as a route + anywhere else — chips in each node's own hue where the terminal can draw them, + arrows where it can't. Hops read as names, nothing else: an unnamed hop, having + no name to show, stands in its own hash at the device's path-hash width (muted + grey — colour is the "this is a name" signal), and no hop repeats its hash after + its name. The hash bytes are the graph's job, one row up; what ties the two + together is the shared node hue, not a second spelling of the hex. """ row = Text() row.append(arrival.when.astimezone().strftime("%H:%M:%S"), style="muted") @@ -223,11 +228,11 @@ class MessagePathsScreen(Screen): if arrival.hops: row.append("via ", style="muted") row.append_text( - path_text( + path_line( arrival.hops, self._resolve, prefix_bytes=self._prefix_bytes, self_name=self._self_name, - empty="direct", show_hash=True, hash_bytes=1, hash_as_name=True, - ) + empty="direct", hash_as_name=True, + ).text() ) if arrival.resend: row.append(f" (resend #{arrival.resend})", style="muted") diff --git a/meshterm/ui/path_composer.py b/meshterm/ui/path_composer.py index a6bf7c2..d8b9d09 100644 --- a/meshterm/ui/path_composer.py +++ b/meshterm/ui/path_composer.py @@ -17,7 +17,7 @@ sent to the device is one walk that ends within our earshot): yours to choose. An *Auto* action hands routing back to the device. * **Path mode** (*Trace path* — no target at all): the whole walk is yours, hop by hop, until the route comes back within earshot of us — only the final landing on - our own node is filled in (and dimmed). The preview opens as just ``★ → ★``. + our own node is filled in (and dimmed). The preview opens as just ``★ → + → ★``. There is no *Auto* action, because with no destination there is nothing for the device to route to. @@ -25,12 +25,12 @@ Interaction, following the reorder screen's cursor-over-rows-and-actions pattern * ↑/↓ move over suggestions and the action rows; Enter on a suggestion inserts it at the route's insertion cursor. -* ←/→ slide that insertion cursor along the editable leg's joining arrows — drawn - as a reverse-video ``→`` — so a hop can be spliced in (to the cursor's right) or - removed (⌫, to its left) anywhere in the route, not just at the end. It opens on - the last arrow, where inserting is appending — the classic flow unchanged. The - suggestion list follows: it always proposes next hops from the node left of the - cursor. +* ←/→ slide that insertion cursor along the editable leg — it rides *in* the route as + a hop of its own, the brand-accent ``+`` slot the next chosen node drops into — so a + hop can be spliced in (at the slot) or removed (⌫, to its left) anywhere in the + route, not just at the end. It opens at the end of the composed leg, where inserting + is appending — the classic flow unchanged. The suggestion list follows: it always + proposes next hops from the node left of the cursor. * Typing filters the suggestions by name or hash — and when the typed text is itself even-length hex, an *add custom hop* row appears, so a node we have never observed (or a bare hash from another tool) can be forced into the route. @@ -493,9 +493,13 @@ class PathComposerScreen(Screen): this dialog exists to shape. An unnamed hop still reads as its hash, truncated to the session's chosen path-hash width. - The caller renders the line with the insertion cursor riding the arrow it - stands on (``cursor_arrow``), a reverse-video ``→`` — which pins the preview to - plain arrows: an editor needs the seams its cursor sits in. + The insertion cursor is *in* the route, not between two of its hops: the + ``+`` slot (:data:`~meshterm.ui.pathline.CURSOR_GLYPH`) sits where the next + chosen node will, in the brand accent the row cursor below it wears. Drawing an + insertion point as a position rather than as a gap is what lets the preview stay + the picture the trace window paints — chips and all, rather than falling back to + arrows for the sake of a seam to sit in — and lets a wrapped route carry the + cursor like any other hop instead of stranding it on a line break. """ entries: list[Optional[str]] = [None] entries.extend(self._path_entry(hop) for hop in self._hops) @@ -510,6 +514,9 @@ class PathComposerScreen(Screen): device_hash=self._device_hash or None, dim_from=(2 if self._mirrored else 1) + len(self._hops), bare_self=True, + # The entries open on our own ★, so arrow index k — the slot after display + # node k — is rendered-hop index k + 1. + cursor=self._cursor + 1, ) def _suggestion_text(self, suggestion) -> Text: # noqa: ANN001 @@ -556,9 +563,9 @@ class PathComposerScreen(Screen): def dialog_width(self) -> int: """Natural outer width hugging the widest row (compositor still caps it).""" widths = [cell_len(self.title), cell_len(self.footer_hint)] - # Measured with the cursor so the width matches the body's plain render (a - # cursor arrow and a plain arrow are both three cells — never a mismatch). - widths.append(self._route_preview().text(cursor_arrow=self._cursor).cell_len) + # The preview carries its own cursor slot, so the measured line is the drawn + # line — same hops, same mode, same width. + widths.append(self._route_preview().text().cell_len) if first_repeated_edge(self._walk_nodes()) is not None: widths.append(cell_len(_NOT_A_TRAIL)) for kind, payload in self._rows(): @@ -576,13 +583,11 @@ class PathComposerScreen(Screen): self._index = max(0, min(self._index, len(rows) - 1)) # The preview breaks at hop boundaries under a 2-column hanging indent (never - # mid-name), the insertion cursor always on its seam — even when the seam is a - # line break, where it rides that line's trailing arrow. + # mid-name, never mid-chip); the insertion slot is one of those hops, so it + # lands on a line like the rest and is always visible. lines = [ render_to_ansi(line, width, no_wrap=True) - for line in self._route_preview().wrapped( - width, indent=2, cursor_arrow=self._cursor - ) + for line in self._route_preview().wrapped(width, indent=2) ] if first_repeated_edge(self._walk_nodes()) is not None: lines.extend(render_lines(Text(_NOT_A_TRAIL, style="warn"), width)) diff --git a/meshterm/ui/pathline.py b/meshterm/ui/pathline.py index 97c9bfb..2f8c266 100644 --- a/meshterm/ui/pathline.py +++ b/meshterm/ui/pathline.py @@ -40,9 +40,11 @@ drawn*, so every surface the survey found can eventually route through it: that *are* news. Existing call sites still render through ``path_text``; migrating them here is a -separate, per-surface pass. The composer's insertion cursor is the one deliberate -mode exception: a render asked for ``cursor_arrow`` always draws plain arrows, since -an insertion point lives *between* hops and chips fuse that seam shut. +separate, per-surface pass. The composer's insertion cursor rides along as a hop in +its own right (``cursor=True``, drawn as the :data:`CURSOR_GLYPH` slot): an insertion +point *is* a position in the route, not a gap between two of them, so it measures, +wraps and colours like every other hop — the preview keeps whichever mode the terminal +earned, and a cursor can never be stranded on a line break. """ from __future__ import annotations @@ -75,6 +77,12 @@ POWERLINE_ROUND_CLOSE = "" #: (``bare_self``): the app-wide ``★`` — the same mark the map plants on us. SELF_GLYPH = "★" +#: The composer's insertion cursor, standing in the route as a hop of its own: the +#: empty slot the next chosen hop drops into. A ``+`` because that is exactly what +#: Enter does there — and because no node is ever named one, so the slot can't be +#: misread as a hop that is already in the route. +CURSOR_GLYPH = "+" + #: The plain-mode joining arrow, exactly as ``path_text`` draws it today. _ARROW = " → " @@ -123,6 +131,10 @@ class PathHop: style: Explicit colour override — a hex style (``"#rrggbb"``, attributes allowed) or theme name — for context colourings that outrank identity. Plain mode uses it as the label style; powerline as the chip fill. + cursor: This hop is an editor's insertion slot rather than a node — the + :data:`CURSOR_GLYPH` in the app's brand accent, the same colour the list + cursor (``❯``) below it wears, so the two halves of one gesture (the row + you pick, the slot it lands in) read as one thing. """ label: str @@ -132,6 +144,7 @@ class PathHop: lit_bytes: int = 0 dim: bool = False style: Optional[str] = None + cursor: bool = False def _style_hex(style: str) -> Optional[str]: @@ -186,21 +199,15 @@ class PathLine: # --- the three shapes --------------------------------------------------------- - def text(self, *, cursor_arrow: Optional[int] = None) -> Text: + def text(self) -> Text: """The full one-line rendering; overflow is the caller's call, per surface. - Args: - cursor_arrow: Draw this joining gap (0-based, between hops *j* and - *j+1*) as the composer's reverse-video insertion cursor. Forces - plain mode for the render — chips fuse the seam an insertion - point needs to sit in. - Returns: A one-line :class:`Text` (the ``empty`` note when there are no hops). """ if not self._hops: return Text(self._empty, style="muted") - return self._render(self._hops, cursor_arrow=cursor_arrow) + return self._render(self._hops) def ellipsized(self, width: int) -> Text: """The line fitted to ``width`` by eliding *middle* hops behind ``⋯``. @@ -231,9 +238,7 @@ class PathLine: last.truncate(width, overflow="ellipsis") return last - def wrapped( - self, width: int, *, indent: int = 0, cursor_arrow: Optional[int] = None - ) -> list[Text]: + def wrapped(self, width: int, *, indent: int = 0) -> list[Text]: """The line broken at hop boundaries, hanging under ``indent`` columns. The hanging-indent convention: the caller lays its label lane on the first @@ -258,9 +263,6 @@ class PathLine: Args: width: The full line budget, indent included. indent: The hanging-indent column the continuations align under. - cursor_arrow: The composer's insertion cursor, as in :meth:`text` (0-based - seam index). Forces plain arrows; a cursor sitting exactly on a line - break lands on that line's trailing cue, so it is always visible. Returns: The lines, in order (a single ``empty`` note when there are no hops). @@ -268,7 +270,7 @@ class PathLine: if not self._hops: return [Text(self._empty, style="muted")] budget = max(1, width - indent) - plain = cursor_arrow is not None or self._resolved_mode() != "powerline" + plain = self._resolved_mode() != "powerline" # The continuation cue is the separator's own mark, minus the space the next # hop would have sat in: " → " reads " →", a wire spec's "," stays ",". cue = self._separator.rstrip() if plain else "" @@ -283,15 +285,11 @@ class PathLine: groups = legs lines: list[Text] = [] - base = 0 # the global index of the group's first hop, for cursor mapping for i, group in enumerate(groups): - local: Optional[int] = None - if cursor_arrow is not None and 0 <= cursor_arrow - base < len(group) - 1: - local = cursor_arrow - base step = 0 if i == 0 else WRAP_OFFSET line = Text() if i == 0 else Text(" " * (indent + step)) body = self._render( - group, cursor_arrow=local, force_plain=plain, + group, force_plain=plain, carry_in=bool(i), carry_on=i < len(groups) - 1, ) # A lone hop too wide for the column is truncated — leaving room for the @@ -303,12 +301,7 @@ class PathLine: body.truncate(room, overflow="ellipsis") line.append_text(body) if continues: - if cursor_arrow == base + len(group) - 1: # the cursor rides the break - line.append(cue[:-1]) - line.append(cue[-1], style="selected") - else: - line.append(cue, style="muted") - base += len(group) + line.append(cue, style="muted") lines.append(line) return lines @@ -478,32 +471,25 @@ class PathLine: self, hops: list[PathHop], *, - cursor_arrow: Optional[int] = None, force_plain: bool = False, carry_in: bool = False, carry_on: bool = False, ) -> Text: - """Join ``hops`` in the effective mode (plain whenever a cursor is asked). + """Join ``hops`` in the effective mode. The two carry flags mark a wrapped line's ends and only mean anything to chips; arrow mode says the same thing with the trailing cue. """ - if not force_plain and cursor_arrow is None and self._resolved_mode() == "powerline": + if not force_plain and self._resolved_mode() == "powerline": return self._render_chips(hops, carry_in=carry_in, carry_on=carry_on) - return self._render_plain(hops, cursor_arrow) + return self._render_plain(hops) - def _render_plain(self, hops: list[PathHop], cursor_arrow: Optional[int]) -> Text: + def _render_plain(self, hops: list[PathHop]) -> Text: """Arrow-joined hops — ``path_text``'s presentation, hop by hop.""" text = Text() for i, hop in enumerate(hops): if i: - gap = self._separator - if cursor_arrow is not None and i - 1 == cursor_arrow: - text.append(gap[: len(gap) - len(gap.lstrip())]) - text.append(gap.strip(), style="selected") - text.append(gap[len(gap.rstrip()) :]) - else: - text.append(gap, style="faint" if hop.dim else "muted") + text.append(self._separator, style="faint" if hop.dim else "muted") text.append_text(self._plain_hop(hop)) return text @@ -511,7 +497,11 @@ class PathLine: """One arrow-mode hop: label in its identity style, annotation muted.""" note_style = "faint" if hop.dim else "muted" text = Text() - if hop.dim: + if hop.cursor: + # No chip fill to carry the accent, so the slot takes the app's reverse-video + # `selected` — the same block the editor's cursor has always been. + text.append(hop.label, style="selected") + elif hop.dim: text.append(hop.label, style="faint") elif hop.style: text.append(hop.label, style=hop.style) @@ -610,14 +600,19 @@ class PathLine: return text def _chip_fill(self, hop: PathHop) -> str: - """A chip's fill colour: override, dim slate, white you, hue, keyless grey. + """A chip's fill: cursor accent, override, dim slate, white you, hue, grey. - The fade outranks identity — including our own. A dimmed hop is one nobody - composed (an automatic landing back home, a mirrored return leg), and the white - ``you`` chip is the loudest thing on the line: our automatic end must recede - with the rest of the automatic half, not shout over the hops that *are* news. - Plain mode says the same thing by fading the name. + The insertion slot outranks everything — it is the one chip that isn't a node, + and it wears the brand accent so it reads as chrome among identities rather than + as a hop with an unlucky hue. The fade comes next, outranking identity including + our own. A dimmed hop is one nobody composed (an automatic landing back home, a + mirrored return leg), and the white ``you`` chip is the loudest thing on the + line: our automatic end must recede with the rest of the automatic half, not + shout over the hops that *are* news. Plain mode says the same thing by fading + the name. """ + if hop.cursor: + return _style_hex("brand") or _KEYLESS_BG if hop.style: resolved = _style_hex(hop.style) if resolved: @@ -631,9 +626,7 @@ class PathLine: return _KEYLESS_BG def _chip(self, hop: PathHop, fill: str) -> Text: - """One chip: same words as arrow mode, dark ink on the identity fill. - -""" + """One chip: same words as arrow mode, dark ink on the identity fill.""" ink = _DIM_FG if hop.dim else _CHIP_FG soft = _DIM_FG if hop.dim else _CHIP_FG_SOFT text = Text() @@ -670,6 +663,7 @@ def path_line( dim_from: Optional[int] = None, hash_as_name: bool = False, bare_self: bool = False, + cursor: Optional[int] = None, mode: str = "auto", ) -> PathLine: """Build a :class:`PathLine` from raw hop hashes — ``path_text``'s vocabulary. @@ -707,6 +701,11 @@ def path_line( out from us and coming home to us are fixtures of every such route, no more composed — or removable — than a mirrored return leg is, so they wear the same automatic grey. + cursor: Splice an editor's insertion slot (:data:`CURSOR_GLYPH`) in *at* this + rendered-hop index — the position a chosen hop would take, so index ``0`` + opens the route and ``len`` closes it. Counted over rendered hops exactly + as ``dim_from`` is, and applied after them, so a slot never shifts what a + hop shows or fades. ``None`` draws no cursor. mode: The :class:`PathLine` mode (``"auto"``/``"powerline"``/``"plain"``). Returns: @@ -748,4 +747,6 @@ def path_line( built.append(PathHop(identity, annotation=note)) else: built.append(PathHop(compact, key=hop, lit_bytes=prefix_bytes)) + if cursor is not None: + built.insert(max(0, min(cursor, len(built))), PathHop(CURSOR_GLYPH, cursor=True)) return PathLine(built, mode=mode, empty=empty) diff --git a/meshterm/ui/widgets.py b/meshterm/ui/widgets.py index 421d85f..2840981 100644 --- a/meshterm/ui/widgets.py +++ b/meshterm/ui/widgets.py @@ -254,7 +254,6 @@ def path_text( device_hash: Optional[str] = None, dim_from: Optional[int] = None, hash_as_name: bool = False, - cursor_arrow: Optional[int] = None, ) -> Text: """Render a hop sequence compactly on one line — THE path widget. @@ -294,11 +293,6 @@ def path_text( hash_as_name: Present each unnamed hop as its identity hash — muted grey at the ``prefix_bytes`` width, annotated with its addressed byte (``hash_bytes``) like a named hop — rather than the compact prefix-lit addressed hash. - cursor_arrow: Draw this joining arrow (0-based: arrow *j* joins rendered hops - *j* and *j+1*) as an insertion cursor — the ``→`` glyph in the - reverse-video ``selected`` block, winning over ``dim_from`` — where the - path composer edits its route. ``None`` (everywhere else) draws every - arrow plain. Returns: A one-line :class:`Text`. Space tighter than the path is the caller's call, @@ -312,12 +306,7 @@ def path_text( for i, hop in enumerate(shown): dim = dim_from is not None and i >= dim_from if i: - if cursor_arrow is not None and i - 1 == cursor_arrow: - text.append(" ") - text.append("→", style="selected") - text.append(" ") - else: - text.append(" → ", style="faint" if dim else "muted") + text.append(" → ", style="faint" if dim else "muted") text.append_text( _path_node( hop, resolve, prefix_bytes=prefix_bytes, self_name=self_name, diff --git a/tests/test_message_paths_screen.py b/tests/test_message_paths_screen.py index ed559bb..64bf907 100644 --- a/tests/test_message_paths_screen.py +++ b/tests/test_message_paths_screen.py @@ -55,21 +55,21 @@ def test_paths_screen_renders_graph_rows_and_cursor() -> None: def test_paths_screen_labels_every_relay_with_its_hash_byte() -> None: - """Graph relays carry their first hash byte, both paths at once; names stay - in the rows, annotated with the same byte.""" + """Graph relays carry their first hash byte, both paths at once; the rows carry the + names alone — no hash repeated after one, the two tied together by the node's hue.""" screen = _screen(_arrivals()) body = _plain(screen.render_body(76)) graph = body.split("origin →")[0] for byte in ("3d", "a1", "77"): # every relay labelled, selected or not assert byte in graph assert "YUL-Cartier" not in graph and "Waymarker" not in graph - assert "YUL-Cartierville (3d)" in body # the rows carry name + hash byte - assert "Waymarker (a1)" in body + assert "YUL-Cartierville" in body and "Waymarker" in body + assert "(3d)" not in body and "(a1)" not in body # the hex lives on the graph def test_paths_screen_shows_unknown_relay_as_grey_mode_width_hash() -> None: """An unnamed relay stands in its own hash at the device's path-hash width, muted - grey and annotated with its addressed byte — not the bare one-byte prefix, lit.""" + grey — not the bare one-byte prefix, lit, and never a hash annotated onto itself.""" now = utcnow() arrivals = [ Arrival(when=now, hops=("3d63",), snr=4.0), # selected: a named relay @@ -78,7 +78,7 @@ def test_paths_screen_shows_unknown_relay_as_grey_mode_width_hash() -> None: screen = _screen(arrivals, prefix_bytes=3) # 3-byte routing → e839f2 lines = screen.render_body(76) row = next(ln for ln in lines if "e839f2" in _plain([ln])) - assert "e839f2 (e8)" in _plain([row]) # mode-width identity, then the byte + assert "(e8)" not in _plain([row]) # the mode-width identity stands alone assert "38;2;148;163;184" in row # muted grey over the hash assert "38;2;94;234;212" not in row # never the brand highlight graph = _plain(lines).split("origin →")[0] diff --git a/tests/test_pathline.py b/tests/test_pathline.py index fc7c4eb..35af46d 100644 --- a/tests/test_pathline.py +++ b/tests/test_pathline.py @@ -14,8 +14,8 @@ import pytest import meshterm.ui.pathline as pathline from meshterm.ui.pathline import ( - POWERLINE_ROUND_CLOSE, POWERLINE_ROUND_OPEN, POWERLINE_SEP, SELF_GLYPH, - WRAP_OFFSET, PathHop, PathLine, _style_hex, path_line, + CURSOR_GLYPH, POWERLINE_ROUND_CLOSE, POWERLINE_ROUND_OPEN, POWERLINE_SEP, + SELF_GLYPH, WRAP_OFFSET, PathHop, PathLine, _style_hex, path_line, ) from meshterm.ui.theme import node_style from meshterm.ui.widgets import path_text @@ -126,14 +126,18 @@ def test_auto_mode_follows_the_terminal_verdict(monkeypatch: pytest.MonkeyPatch) assert PathLine(hops).text().plain == "a → b" -def test_cursor_arrow_forces_plain_and_reverse_videos_the_seam() -> None: - """The composer's insertion cursor needs a seam to sit in, so a cursor render - is always arrows — even in powerline mode — with the cursor arrow selected.""" - hops = [PathHop("a"), PathHop("b")] - text = PathLine(hops, mode="powerline").text(cursor_arrow=0) - assert POWERLINE_SEP not in text.plain - assert text.plain == "a → b" - assert _styles(text)["→"] == "selected" +def test_the_cursor_is_a_hop_of_its_own_in_either_mode() -> None: + """The composer's insertion point is a slot *in* the route, not a gap between two + hops: it renders as a hop, so chips stay chips and arrows stay arrows.""" + hops = [PathHop("a"), PathHop(CURSOR_GLYPH, cursor=True), PathHop("b")] + plain = PathLine(hops, mode="plain").text() + assert plain.plain == f"a → {CURSOR_GLYPH} → b" + assert _styles(plain)[CURSOR_GLYPH] == "selected" # the reverse-video block + + chips = PathLine(hops, mode="powerline").text() + assert chips.plain == f" a {POWERLINE_SEP * 2} {CURSOR_GLYPH} {POWERLINE_SEP * 2} b {POWERLINE_SEP}" + slot = next(s for s in chips.spans if chips.plain[s.start : s.end] == CURSOR_GLYPH) + assert str(slot.style).endswith(f"on {_style_hex('brand')}") # the brand accent fill def test_ellipsized_elides_the_middle_and_keeps_both_endpoints() -> None: @@ -287,19 +291,33 @@ def test_path_line_factory_matches_path_text_hash_as_name_flavour() -> None: assert path_line([], lambda h: h).text().plain == path_text([], lambda h: h).plain -def test_wrapped_carries_the_cursor_even_onto_a_line_break() -> None: - """A cursor render is plain even for a powerline line; a cursor sitting mid-group - reverse-videos its arrow, and one sitting exactly on the break rides that line's - trailing cue — the insertion point is always visible.""" - hops = [PathHop(label) for label in ("AAAA", "BBBB", "CCCC", "DDDD")] - line = PathLine(hops, mode="powerline") - mid = line.wrapped(16, indent=2, cursor_arrow=0) - assert [text.plain for text in mid] == ["AAAA → BBBB →", " CCCC → DDDD"] - assert any(str(s.style) == "selected" for s in mid[0].spans) - assert all(POWERLINE_SEP not in text.plain for text in mid) - on_break = line.wrapped(16, indent=2, cursor_arrow=1) # the seam that broke - assert str(on_break[0].spans[-1].style) == "selected" - assert not any(str(s.style) == "selected" for s in on_break[1].spans) +def test_path_line_factory_splices_the_cursor_slot_at_a_hop_index() -> None: + """``cursor`` names the position a chosen hop would take, counted over the rendered + hops and applied after them — so the slot never shifts what its neighbours show.""" + hops = [None, "aa11bb", "3d63ab", None] + + def built(cursor) -> str: # noqa: ANN001 + line = path_line(hops, self_name="Me", bare_self=True, mode="plain", cursor=cursor) + return line.text().plain + + assert built(None) == f"{SELF_GLYPH} → aa11bb → 3d63ab → {SELF_GLYPH}" + assert built(0) == f"{CURSOR_GLYPH} → {SELF_GLYPH} → aa11bb → 3d63ab → {SELF_GLYPH}" + assert built(2) == f"{SELF_GLYPH} → aa11bb → {CURSOR_GLYPH} → 3d63ab → {SELF_GLYPH}" + assert built(99).endswith(f"{SELF_GLYPH} → {CURSOR_GLYPH}") # clamped to the tail + + +def test_wrapped_carries_the_cursor_like_any_other_hop() -> None: + """A folded route can't strand its insertion point: the slot is a hop, so it lands + on a line of its own accord — wherever it stands, and whatever the mode.""" + labels = ("AAAA", "BBBB", "CCCC", "DDDD") + for at in range(len(labels) + 1): + hops = [PathHop(label) for label in labels] + hops.insert(at, PathHop(CURSOR_GLYPH, cursor=True)) + lines = PathLine(hops, mode="plain").wrapped(16, indent=2) + assert sum(text.plain.count(CURSOR_GLYPH) for text in lines) == 1 + assert all(text.cell_len <= 16 for text in lines) + carried = next(t for t in lines if CURSOR_GLYPH in t.plain) + assert any(str(s.style) == "selected" for s in carried.spans) def test_wrapped_chip_lines_open_on_the_break_they_continue() -> None: diff --git a/tests/test_topology.py b/tests/test_topology.py index 070fd6b..da1d493 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -23,8 +23,9 @@ from meshterm.persistence.repository import ( from meshterm.services.path_probe import ProbeCandidate, ProbeOutcome, probe_paths from meshterm.services.topology import build_topology, collapse_width, render_custom_spec from meshterm.services.trace_runner import make_node_resolver +import meshterm.ui.pathline as pathline from meshterm.ui.path_composer import AUTO_SPEC, FetchNeighbours, PathComposerScreen -from meshterm.ui.pathline import SELF_GLYPH +from meshterm.ui.pathline import CURSOR_GLYPH, SELF_GLYPH from meshterm.ui.tui.screen import CANCEL US = "aaaaaaaaaaaa" @@ -507,18 +508,19 @@ def test_composer_suggestions_follow_the_cursor_anchor() -> None: assert all(s.node != "3d63c6429436" for s in screen._suggestions()) -def test_composer_preview_draws_the_cursor_on_its_arrow() -> None: - """One reverse-video arrow marks the insertion point, and it rides the cursor.""" +def test_composer_preview_stands_the_cursor_in_the_route() -> None: + """The insertion point is a hop of the route — the ``+`` slot the next node takes — + and ←/→ slide it along, one position at a time.""" screen = _composer(_topo(), hops=["3d63c6429436"], target=False) - def cursor_at() -> list[int]: - text = screen._route_preview().text(cursor_arrow=screen._cursor) - return [s.start for s in text.spans if str(s.style) == "selected"] + def slot_at() -> int: + labels = [hop.label for hop in screen._route_preview().hops] + assert labels.count(CURSOR_GLYPH) == 1 # exactly one slot, always + return labels.index(CURSOR_GLYPH) - (end,) = cursor_at() # exactly one cursor arrow + assert slot_at() == 2 # opens after us and the composed hop: inserting is appending screen.handle("left") - (home,) = cursor_at() - assert home < end # the block slid left along the preview + assert slot_at() == 1 # slid home, before the hop it would now precede def test_composer_warns_when_the_walk_repeats_a_link() -> None: @@ -609,17 +611,20 @@ async def test_composer_commits_spec_auto_and_cancel() -> None: assert cancelled.future.result() is CANCEL -def test_composer_path_mode_opens_star_to_star_without_auto() -> None: - """Target-less, the preview is just ``★ → ★`` and there is no Auto action. +def test_composer_path_mode_opens_star_to_star_without_auto(monkeypatch) -> None: # noqa: ANN001 + """Target-less, the preview is just ``★ → + → ★`` and there is no Auto action. Both ends are our own node, bare and faded: a walk always leaves us and comes home to us, and neither end is the user's to compose or remove — the automatic grey says - so. With no destination there is nothing for the device to route to either. + so. Between them waits the empty slot. With no destination there is nothing for the + device to route to either. """ + monkeypatch.setattr(pathline, "powerline_enabled", lambda: False) # assert the words screen = _composer(_topo(), target=False) assert screen.title == "Compose path" # no target to name - preview = screen._route_preview().text(cursor_arrow=screen._cursor) - assert preview.plain == f"{SELF_GLYPH} → {SELF_GLYPH}" # our name and hash go unsaid + preview = screen._route_preview().text() + # Our name and hash go unsaid; the slot says where the first hop would land. + assert preview.plain == f"{SELF_GLYPH} → {CURSOR_GLYPH} → {SELF_GLYPH}" stars = [ str(span.style) for span in preview.spans if preview.plain[span.start : span.end] == SELF_GLYPH diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 8e2b1ff..5d6560e 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -112,18 +112,6 @@ def test_path_text_dims_the_tail_from_dim_from() -> None: assert any(run.startswith("YUL") and style != "faint" for run, style in styles) -def test_path_text_cursor_arrow_reads_as_a_reverse_block() -> None: - """The composer's insertion cursor: that one arrow renders as a selected block.""" - text = path_text(["aa", "77", "3d"], _resolve, cursor_arrow=1) - assert text.plain == "Alice → 77 → YUL" # spacing survives the split append - styles = [(text.plain[s.start : s.end], str(s.style)) for s in text.spans] - assert styles.count(("→", "selected")) == 1 # only arrow 1, between 77 and YUL - assert (" → ", "muted") in styles # the other arrow stays plain - # The cursor wins over dim_from: the arrow into a dimmed tail still reads selected. - dimmed = path_text([None, "aa", None], _resolve, self_name="us", dim_from=2, cursor_arrow=1) - assert any(str(s.style) == "selected" for s in dimmed.spans) - - # --- the name→key resolver and the keyless-stays-muted rule --------------------------