diff --git a/meshterm/ui/pathline.py b/meshterm/ui/pathline.py index 8c77940..4e8a4dc 100644 --- a/meshterm/ui/pathline.py +++ b/meshterm/ui/pathline.py @@ -42,7 +42,9 @@ drawn*, so every surface the survey found can eventually route through it: 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. + 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 @@ -725,6 +727,7 @@ def path_line( 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: @@ -759,10 +762,15 @@ def path_line( 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. Both stars also fade: setting - 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. + 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 @@ -779,11 +787,13 @@ def path_line( dim = dim_from is not None and i >= dim_from if hop is None: if bare_self: - # Both our ends are fixed: a route leaves us and comes home to us, and - # neither end is anybody's to compose or remove. So the stars fade like - # every other automatic hop — the colour on the line belongs to the - # nodes actually being chosen. - built.append(PathHop(SELF_GLYPH, you=True, dim=True)) + # 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( diff --git a/meshterm/ui/records_screen.py b/meshterm/ui/records_screen.py index 447f8be..ef556d7 100644 --- a/meshterm/ui/records_screen.py +++ b/meshterm/ui/records_screen.py @@ -6,15 +6,20 @@ opened from the main menu. Every successful trace — a *Trace target* boomerang (see :mod:`~meshterm.services.records`); this screen is where the survivors live. * the browser groups the six disciplines, each under its heading with a one-line - description of the game (word-wrapped when it must), then that discipline's records - ranked best-first — dated, scored in the discipline's own unit, with the walked route - through THE path widget. A discipline holding records at more than one hash width tags - each row with its width, since the widths are genuinely different games; + description of the game (word-wrapped when it must) — the two pin overhead together as + one block while that board scrolls, so a row deep in a discipline still says which game + it is winning and what that game scores — then the discipline's records ranked + best-first: the day, the score in the discipline's own unit, and the walk itself on THE + path widget with both ends bare (every record is a boomerang, so the cells go to the + hops). A discipline holding records at more than one hash width tags each row with its + width, since the widths are genuinely different games; * opening a record floats :class:`RecordDialog` — every stat the walk was measured by (the far point named with the node it reached), the walk drawn two ways: on THE route - graph (the Message paths dialog's shape, us at both ends) and, beside the stats, as the - enclosed area it swept on a braille mini-map (us and every positioned hop, coloured node - pins, no labels); then the full route and spec, and when/by which app version it was + graph (the Message paths dialog's shape, us at both ends, drawn no taller than one lane + needs) and, beside the stats, as the enclosed area it swept on a braille mini-map (us and + every positioned hop, coloured node pins, no labels); then the full route — the path + widget again, unlabelled across the card's whole width, wrapping at hop boundaries — and + the spec, and when/by which app version it was set. The card scrolls (PgUp/PgDn/Home/End) when it outgrows the terminal. From there *Trace this path* reopens Trace path with the record's route prefilled, so a claim worth re-testing is one Enter from the air again — and when that screen closes, the @@ -33,6 +38,7 @@ import textwrap from dataclasses import dataclass from typing import TYPE_CHECKING, Optional, Sequence +from rich.cells import cell_len from rich.text import Text from ..core.geo import haversine_km @@ -40,8 +46,9 @@ from ..persistence.repository import DiscoveredPath from ..services import trace_runner from ..services.records import CATEGORIES, CATEGORY_BY_ID, Category, _local_xy from .mapcanvas import RGB, MapCanvas -from .menus import back_rows, section_heading +from .menus import back_rows, fit_cells, section_heading from .pathgraph import PathLayer, render_path_graph +from .pathline import path_line from .theme import name_style, snr_style from .tui.render import render_hanging, render_to_ansi from .tui.screen import Screen @@ -51,7 +58,6 @@ from .widgets import ( name_rgb, node_marker, node_type_legend, - path_text, route_graph_style, self_marker, ) @@ -67,6 +73,12 @@ _DESC_WRAP = 64 #: on the canvas, so it needs no colour to set it apart — white just reads as "the walk". _WALK_EDGE = (255, 255, 255) +#: Canvas rows a record's route graph draws in. One walk is one lane, so the graph is flat +#: and needs only its marker row with a row either side for the labels — the shared widget's +#: own default (5) leaves a scored walk sitting in two rows of blank canvas. A fan would grow +#: past this on its own; nothing here ever fans. +_GRAPH_ROWS = 3 + #: The area drawing's one body tone — a plain muted slate the enclosed region and its loop #: both draw in. Deliberately a single shade (no dimmer interior wash under a brighter rim): #: an even-odd fill shades only the enclosed side, and the coloured node pins carry the @@ -83,6 +95,25 @@ _POLY_EXTRA_ROWS = 2 _SIDE_BY_SIDE_MIN = 44 +def _drawn_rows(lines: list[str]) -> list[str]: + """``lines`` with the rows nothing actually landed on stripped from either end. + + Both blocks the card stacks are drawn on a canvas sized for the worst case and then + filled: the stats' area drawing keeps :data:`_POLY_EXTRA_ROWS` in hand for a tall shape, + and the route graph pads a row either side of its markers for the labels. A shape that + comes out flat, or labels that all seat on one side, leave those rows empty — and an + empty row still costs a line of card, stacking up as a gap the layout never intended + (three blank lines between the stats and the graph where one was meant). Trimming happens + *after* the drawing, so nothing placed is ever lost: only rows that stayed blank go. + """ + kept = list(lines) + while kept and not Text.from_ansi(kept[-1]).plain.strip(): + kept.pop() + while kept and not Text.from_ansi(kept[0]).plain.strip(): + kept.pop(0) + return kept + + @dataclass(frozen=True, slots=True) class WalkVertex: """One positioned point of a walk's circuit, projected onto the local km plane. @@ -114,7 +145,8 @@ class RecordDialog(Screen): drawn beside them on a braille mini-map (us at the origin, every positioned hop pinned in its own name hue, no labels) when the terminal has the room; below, the walk on THE route graph (us at both ends, relays wearing their map marker over a node-type key), - the route in full (wrapped, never truncated), and the + the route in full on THE path widget — no label lane, the card's whole width, wrapped + at hop boundaries and never truncated — and the record's provenance — when it was set and by which app version. The card scrolls (PgUp/PgDn/Home/End) when it outgrows the frame, while the arrows drive the actions. Two actions besides Back: *Trace this path* reopens Trace path with the record's route @@ -235,6 +267,12 @@ class RecordDialog(Screen): re-walking one node must never make the shape look bigger than the ground it covered — one node, one marker, whatever the spec asked for. An overheard via chain has the opposite duty: nothing there is ours to inflate, and a repeat is evidence. + + One walk is one lane, so the graph draws in :data:`_GRAPH_ROWS` — the marker row and + a row either side for the labels — rather than the widget's default, which reserves + room for a fan this canvas never carries and spends it on blank rows. Whichever of + those two label rows stays empty is handed back (:func:`_drawn_rows`), so the graph + sits exactly one blank line under the stats and the caption follows it directly. """ seen: set[str] = set() hops: list[str] = [] @@ -248,11 +286,12 @@ class RecordDialog(Screen): source=self._device_label, type_of=self._type_of, ) - return render_path_graph( + return _drawn_rows(render_path_graph( [PathLayer(hops=tuple(hops), color=_WALK_EDGE, priority=3)], width, glyph_of=glyph_of, label_of=label_of, label_rgb_of=label_rgb_of, - ) + min_rows=_GRAPH_ROWS, + )) def _stat_lanes(self) -> list[Text]: """Every stat the walk was measured by, as label/value lanes (score → round trip). @@ -314,7 +353,10 @@ class RecordDialog(Screen): With a drawable walk and room to spare, the polygon takes a fixed cell box on the right and the lanes are cropped to the column beside it; too narrow, or no shape, - and the lanes reclaim the whole width and the drawing is dropped. + and the lanes reclaim the whole width and the drawing is dropped. The block is + trimmed to the rows something landed on (:func:`_drawn_rows`), so a shape that comes + out flat hands its unused rows back instead of leaving the card gaping under the + last stat. """ if not self._shape or width < _SIDE_BY_SIDE_MIN: return [render_to_ansi(lane, width, no_wrap=True) for lane in lanes] @@ -331,7 +373,7 @@ class RecordDialog(Screen): row.append(" ") row.append_text(Text.from_ansi(poly[i])) out.append(render_to_ansi(row, width, no_wrap=True)) - return out + return _drawn_rows(out) def _shape_lines(self, cell_w: int, cell_h: int) -> list[str]: """Draw the walk's enclosed area on a braille canvas: fill, loop, coloured pins. @@ -390,16 +432,28 @@ class RecordDialog(Screen): lines.append(render_to_ansi(node_type_legend(), width, no_wrap=True)) lines.append("") - route = path_text( + # The walk itself, on THE path widget, across the card's whole width. No ``route`` + # label lane: the line under a route graph captioned "you → … → you" is the route, + # and the twelve cells a label would take are hops the reader came here for. It wraps + # at hop boundaries (never mid-name, never mid-chip) rather than hanging under a lane. + # Our two ends stand on the app-wide ★ rather than repeating our name and key — the + # graph above already marks us with the same star at both ends, and the legend under + # it reads that star back as "you" — kept in the ``you`` white, since nothing about a + # walk already made is ours to compose. + route = path_line( [None, *record.route, None], self._resolve, + prefix_bytes=record.width_bytes, self_name=self._device_label, show_hash=True, hash_bytes=record.width_bytes, device_hash=self._device_hash, + bare_self=True, + dim_self=False, + ) + lines.extend( + render_to_ansi(line, width, no_wrap=True) for line in route.wrapped(width) ) - lines.extend(render_hanging(Text("route ", style="muted"), route, width, - indent=12)) spec = Text(record.spec, style="brand") spec.append(f" ({record.width_bytes}-byte hops)", style="muted") lines.extend(render_hanging(Text("spec ", style="muted"), spec, width, @@ -594,30 +648,52 @@ async def open_records(ctx: "AppContext") -> dict: rows.append(Separator(f" {line}", style="muted")) return rows - def browser_row( - rank: int, category: Category, record: DiscoveredPath, *, show_width: bool - ) -> Text: - """One record row: rank, date, score (width when it disambiguates), route.""" - row = Text(f"#{rank} ", style="muted") - row.append(record.discovered_at.astimezone().strftime("%b %d %H:%M"), - style="muted") - row.append(" ") + def scored(category: Category, record: DiscoveredPath) -> str: + """A record's score in its discipline's unit, bounded where the walk was partial.""" score = category.format_score(record.score) if category.id == "long_haul" and not record.stats.get("km_complete", True): score = "≥ " + score - row.append(f"{score:<12}", style="accent") + return score + + def browser_row( + rank: int, + category: Category, + record: DiscoveredPath, + *, + show_width: bool, + score_w: int, + ) -> Text: + """One record row: rank, date, score (width when it disambiguates), then the walk. + + The columns left of the walk are held to what they actually say — the day the record + was set (the dialog carries the time), and a score lane fitted to the widest score on + *this* board rather than a fixed twelve — because every cell they don't spend is a hop + the route gets to show. The walk itself draws on THE path widget with both ends bare + (:data:`~meshterm.ui.pathline.SELF_GLYPH`): a record is a boomerang by construction, + so naming ourselves twice per row would cost more cells than the whole score lane and + tell the reader what every other row already told them. Those stars keep the ``you`` + white (``dim_self=False``) — the fade means "not yours to compose", and nothing on a + board of walks already made is being composed. Overflow stays the list's job — the + browser h-scrolls the highlighted row, so a long walk is read by sliding it, not by + eliding it here. + """ + row = Text(f"#{rank} ", style="muted") + row.append(record.discovered_at.astimezone().strftime("%b %d"), style="muted") + row.append(" ") + row.append(fit_cells(scored(category, record), score_w), style="accent") if show_width: - row.append(f"{record.width_bytes} B ", style="muted") - row.append(" ") + row.append(f" {record.width_bytes} B", style="muted") + row.append(" ") row.append_text( - path_text( + path_line( [None, *record.route, None], resolve, - self_name=device_label, - show_hash=True, + prefix_bytes=record.width_bytes, hash_bytes=record.width_bytes, - device_hash=device_hash, - ) + self_name=device_label, + bare_self=True, + dim_self=False, + ).text() ) return row @@ -664,9 +740,15 @@ async def open_records(ctx: "AppContext") -> dict: if not board: items.append(Separator(" no records yet", style="muted")) show_width = len({r.width_bytes for r in board}) > 1 + # The score lane is this board's own widest score — "3 nodes" and "+6.0 dB" + # measure differently, and a lane sized for the worst case everywhere would + # spend the difference on padding in front of every route. + score_w = max((cell_len(scored(category, r)) for r in board), default=0) for rank, record in enumerate(board, start=1): items.append(Choice( - title=browser_row(rank, category, record, show_width=show_width), + title=browser_row( + rank, category, record, show_width=show_width, score_w=score_w + ), value=("open", category, rank, record), )) total = len(ctx.repo.discoveries()) diff --git a/tests/test_pathline.py b/tests/test_pathline.py index 8205634..130984c 100644 --- a/tests/test_pathline.py +++ b/tests/test_pathline.py @@ -426,3 +426,29 @@ def test_bare_self_stands_us_on_a_star_and_fades_both_ends() -> None: assert len(lines) > 1 assert all(line.plain[2 + WRAP_OFFSET] == POWERLINE_SEP for line in lines[1:]) assert all(line.cell_len <= 28 for line in lines) + + +def test_bare_self_keeps_us_in_the_you_white_where_nothing_is_composed() -> None: + """``dim_self=False`` un-mutes the stars: the fade means "not yours to compose". + + A surface that only *shows* a walk (the Trophy case's boards and record card) composes + nothing, so there is no "not yours" to say and our own node reads in the app-wide ``you`` + white — the same identity colour it wears everywhere else. A ``dim_from`` that reaches a + star still fades it, so a mirrored return leg keeps its grey either way. + """ + hops = [None, *(f"{i:02x}aa" for i in range(4)), None] + text = path_line(hops, prefix_bytes=2, self_name="Me", bare_self=True, + dim_self=False, mode="plain").text() + stars = [s for s in text.spans if text.plain[s.start : s.end] == SELF_GLYPH] + assert [str(s.style) for s in stars] == ["you", "you"] + chips = path_line(hops, prefix_bytes=2, self_name="Me", bare_self=True, + dim_self=False, mode="powerline").text() + fills = [str(s.style) for s in chips.spans + if chips.plain[s.start : s.end] == SELF_GLYPH] + assert all("#ffffff" in fill for fill in fills) # the you white, as a chip fill + + # The explicit fade still rules: a star inside a dimmed return leg stays grey. + faded = path_line(hops, prefix_bytes=2, self_name="Me", bare_self=True, + dim_self=False, dim_from=3, mode="plain").text() + tail = [s for s in faded.spans if faded.plain[s.start : s.end] == SELF_GLYPH] + assert [str(s.style) for s in tail] == ["you", "faint"] diff --git a/tests/test_records_screen.py b/tests/test_records_screen.py index 06476c0..41f489b 100644 --- a/tests/test_records_screen.py +++ b/tests/test_records_screen.py @@ -21,9 +21,10 @@ from meshterm.core.admin_store import AdminStore from meshterm.core.config import Settings from meshterm.core.device_store import DeviceStore from meshterm.persistence.repository import DiscoveredPath, Repository -from meshterm.services.records import CATEGORY_BY_ID +from meshterm.services.records import CATEGORIES, CATEGORY_BY_ID from meshterm.ui.records_screen import RecordDialog, WalkVertex, open_records from meshterm.ui.surface import TuiUi +from meshterm.ui.tui import frame from meshterm.ui.tui.prompt import TypedConfirmDialog from meshterm.ui.tui.screen import CANCEL from meshterm.ui.tui.select import SelectScreen @@ -96,6 +97,42 @@ def test_record_dialog_draws_the_walk_as_a_route_graph() -> None: assert any("⠀" <= ch <= "⣿" for ch in body) # braille edges are drawn +def test_record_graph_is_only_as_tall_as_one_lane_needs() -> None: + """A walk is one path: the marker row, plus a label row only where a label landed. + + The shared widget's default floor (5) reserves room for a fan of alternatives; nothing + here ever fans, so the walk sat in rows of blank canvas and pushed the route and the + actions down the card for nothing. + """ + for record in (_record(), _record(route=(HUB_ID, FAR_ID, HUB_ID, FAR_ID, HUB_ID))): + lines = _dialog(record)._graph_lines(60) # however many relays: still one lane + assert 0 < len(lines) <= 3 + assert all(_plain([line]).strip() for line in lines) # not a blank row among them + + +def test_record_dialog_route_runs_unlabelled_across_the_whole_card() -> None: + """The route is THE path widget at full width — no ``route`` lane eating twelve cells. + + Under a graph captioned ``you → … → you``, the line *is* the route; the label lane only + cost hops. It wraps at hop boundaries, so a long walk folds instead of truncating, and + our own two ends stand on the ★ the graph above already marks us with. + """ + dialog = _dialog(_record(route=tuple([HUB_ID, FAR_ID] * 4))) + body = _plain(dialog.render_body(60)).splitlines() + spec_at = next(i for i, line in enumerate(body) if line.startswith("spec")) + # The route's own first line: the last one before the spec that isn't a hanging fold. + route_at = next(i for i in range(spec_at - 1, 0, -1) if not body[i].startswith(" ")) + route = body[route_at:spec_at] + assert not route[0].startswith("route") # no label lane + assert route[0].startswith("★") # our end opens the walk on the app-wide star… + assert route[-1].endswith("★") # …and closes it on the same + assert "Homestead" not in "".join(route) # never our name, and never our key + assert "YUL-Cartierville" in "".join(route) # the hops themselves are named in full + assert len(route) > 1 # it folded rather than truncating… + # …every fold hanging under the step, and the labelled lanes resume at the spec. + assert all(line.startswith(" ") for line in route[1:]) + + def test_record_dialog_shows_the_node_type_legend() -> None: """The standard node-type key sits under the graph, so its markers read.""" body = _plain(_dialog(_record()).render_body(60)) @@ -330,6 +367,93 @@ async def test_delete_a_disciplines_records_is_a_popup_over_the_browser(tui_ctx) assert result == {"records": 1} +async def test_a_board_row_spends_its_cells_on_the_walk(tui_ctx) -> None: + """A record row leads with rank/day/score, then the walk with both ends bare. + + Every record is a boomerang, so naming ourselves at both ends said nothing twice a row + and cost more cells than the whole score lane; the ends go to ``★`` and the score lane + is fitted to this board's own widest score instead of a fixed twelve. + """ + ctx = tui_ctx + session = ctx.ui.session + ctx.repo.record_discovery( + "grand_tour", 1, "3d,f2", (HUB_ID, FAR_ID, HUB_ID), + score=2.0, stats={"hop_count": 3, "distinct_nodes": 2}, app_version="0.1.0", + ) + + task = asyncio.ensure_future(open_records(ctx)) + try: + browser = await _step_until(lambda: _trophy_case(session)) + assert browser is not None + label = next( + choice.label for choice in browser._choices() + if isinstance(choice.value, tuple) and choice.value[0] == "open" + ) + row = label.plain + assert row.startswith("#1 ") # the standing on the board + assert "2 nodes" in row # the score, in the discipline's unit + assert row.count("★") == 2 # our two ends, bare — never named twice a row + assert "Homestead" not in row # …so the device name is nowhere on the line + # And those ends are *us*, in the you white — nothing here is ours to compose, so + # there is no "not yours" fade to wear. + stars = [s for s in label.spans if row[s.start : s.end] == "★"] + assert [str(s.style) for s in stars] == ["you", "you"] + assert "3d" in row and "f2" in row # the hops, at the record's own hash width + # The lanes before the walk are held to what they say: a score lane fitted to this + # board's widest score (no dead padding), and the day without the minute the dialog + # carries — every cell they don't spend is a hop the route gets to show. + assert "2 nodes ★" in row + assert ":" not in row[: row.index("★")] + finally: + if not task.done(): + task.cancel() + + +async def test_the_board_pins_its_discipline_heading_and_description(tui_ctx) -> None: + """Scrolling into a board keeps its ``── discipline ──`` heading *and* description overhead. + + Every discipline heading is followed by its word-wrapped description — what that board + scores, which is exactly what a reader partway down it needs. The two pin as one block: + with every separator a pinning candidate, the *last muted line* under a heading won the + top row on its own and the heading never stuck — the first discipline's least of all, + since its description is what the very first scrolled row sits under. + """ + ctx = tui_ctx + session = ctx.ui.session + for i in range(6): # one full board, so its records outlast a short viewport + ctx.repo.record_discovery( + "grand_tour", 1, f"3d,{i:02x}", (HUB_ID, FAR_ID), + score=float(i + 1), stats={"hop_count": 2, "distinct_nodes": 2}, + app_version="0.1.0", + ) + + task = asyncio.ensure_future(open_records(ctx)) + try: + browser = await _step_until(lambda: _trophy_case(session)) + assert browser is not None + # Exactly the six disciplines are landmarks, each block led by its own heading and + # carrying the description written under it — nothing else is a candidate. + browser.render_body(72) + blocks = [[_plain([line]).strip() for line in rows] + for _idx, rows in browser._sticky_headers] + assert [rows[0] for rows in blocks] == [f"── {c.icon} {c.title} ──" for c in CATEGORIES] + assert all(len(rows) >= 2 for rows in blocks) # each carries its description too + # Highlighting deep in the one populated board scrolls its heading off the top of a + # short viewport; the heading leads what pins, its description under it. + for _ in range(4): + browser.handle("down") + visible, above, _below = frame._visible_slice(browser, browser.render_body(72), 10) + board = CATEGORY_BY_ID["grand_tour"] + assert _plain([visible[0]]).strip() == f"── {board.icon} {board.title} ──" + assert _plain([visible[1]]).strip().startswith(board.description[:20]) + assert above is True + browser.resolve(("back", None, 0, None)) + await task + finally: + if not task.done(): + task.cancel() + + async def test_trace_this_path_unwinds_to_the_menu_not_the_browser( tui_ctx, monkeypatch ) -> None: