From ea8ae61812e6d03d0710e3d27304211cf902151d Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sat, 12 Sep 2026 04:00:22 -0400 Subject: [PATCH] A record is a tabbed page: Stats, then the walk's ground on an Area tab of its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The node page's shape (widgets.tab_strip, Tab/Shift+Tab, the next tab on F3): Stats is the record read — its stats, identity first, then the route two ways — with the action rows at its foot; Area is the ground drawing across the whole stage the viewport leaves under the strip, so the shape gets every row and column the page has rather than the block a stacked layout could spare it. The Area tab is offered only for a walk with a drawable shape; a record without one keeps a lone-tab heading and nothing to switch. A page too narrow to draw on says so instead of squeezing. widgets.tab_air is the strip's platform-bound air, hoisted from the node page so both tabbed screens spend it the same way. The gallery gains the Area tab as a specimen on both platforms. Co-Authored-By: Claude Fable 5.1 --- meshterm/ui/node_detail_screen.py | 25 +--- meshterm/ui/records_screen.py | 202 +++++++++++++++++++++--------- meshterm/ui/widgets.py | 24 ++++ tests/test_gallery.py | 36 +++++- tests/test_records_screen.py | 138 +++++++++++++------- 5 files changed, 304 insertions(+), 121 deletions(-) diff --git a/meshterm/ui/node_detail_screen.py b/meshterm/ui/node_detail_screen.py index b1a5d6c..459c9be 100644 --- a/meshterm/ui/node_detail_screen.py +++ b/meshterm/ui/node_detail_screen.py @@ -123,6 +123,7 @@ from .widgets import ( format_ago, highlighted_hash, node_type_legend, + tab_air, tab_strip, ) @@ -132,28 +133,14 @@ if TYPE_CHECKING: # pragma: no cover - typing only #: Our own node's marker glyph and hue, matching the map/mesh-walk star. _SELF_GLYPH = ("★", "#facc15") -#: Blank rows of air around the tab strip — one above and one under on the desktop; none -#: on the PicoCalc (JP, 2026-08-08), where rows are the scarce resource and both lines go -#: to the stage instead (the route graph's ceiling, the location preview's growth room). -#: Rides the same frugality signal as the borderless frame, and is bound at -#: platform-switch time like every platform-derived constant — never branched per paint. -_TAB_AIR = 1 - - -@on_platform -def _bind_tab_air(platform: Platform) -> None: - """Bind the strip's air to the platform (runs now and on every switch).""" - global _TAB_AIR - _TAB_AIR = 1 if platform.frame_border else 0 - - #: Whether the route fan is captioned ("node → you, as heard · white = selected route") #: and allowed to keep the empty canvas row its top air leaves. Both go on the PicoCalc #: (JP, 2026-08-09) and both rows go to the graph instead: the caption explains a picture #: the white line and the ``you`` star already make, and the renderer's end padding #: (:data:`~meshterm.ui.pathgraph._GRAPH_END_DOTS`, two cells) reserves a whole cell of air #: above the topmost lane's label that nothing ever draws into. Rides the same frugality -#: signal as :data:`_TAB_AIR`, bound at platform-switch time like every platform constant. +#: signal as the tab strip's air (:func:`~meshterm.ui.widgets.tab_air`), bound at +#: platform-switch time like every platform constant. _GRAPH_CAPTION = True @@ -733,7 +720,7 @@ class NodeDetailScreen(Screen): lines: list[str] = [] lines.extend(render_lines(self._header, width)) if self._tabs: - lines.extend([""] * _TAB_AIR) + lines.extend([""] * tab_air()) lines.extend( render_lines( tab_strip([t.name for t in self._tabs], self._tab_index, width), @@ -762,7 +749,7 @@ class NodeDetailScreen(Screen): if not route_blocks: # The bare note pays for its own air under the strip — where the # platform affords the strip any air at all. - lines.extend([""] * _TAB_AIR) + lines.extend([""] * tab_air()) budget = viewport - len(lines) - action_lines - 1 # the rule's line lines.extend(self._routes_stage(width, budget, route_blocks)) # The rule closes the stage, so the drawn view and the rows below it read as @@ -810,7 +797,7 @@ class NodeDetailScreen(Screen): :meth:`_key_line`), so a row count the preview sizes against can't move as a reader walks a long key. """ - lines: list[str] = [""] * _TAB_AIR # the stage's air under the strip, where afforded + lines: list[str] = [""] * tab_air() # the stage's air under the strip, where afforded for label, value in self._info_rows: if label == _KEY_LABEL: lines.append(self._key_line(label, value, width)) diff --git a/meshterm/ui/records_screen.py b/meshterm/ui/records_screen.py index abb9339..4f07170 100644 --- a/meshterm/ui/records_screen.py +++ b/meshterm/ui/records_screen.py @@ -16,10 +16,8 @@ opened from the main menu. Every successful trace — a *Trace target* boomerang * opening a record pushes :class:`RecordScreen`, a full-screen page — every stat the walk was measured by (the far point named with the node it reached, the longest leg with the link it spanned), - the walk drawn two ways: under the stats, as the enclosed area it swept on a braille - mini-map (north up, us and every positioned hop as coloured node pins, each hop labelled - with its hash byte) and, under that, on THE route graph (the Message paths dialog's - shape, us at both ends, drawn no taller than one lane needs); then the full route — the path + the walk on THE route graph (the Message paths dialog's shape, us at both ends, drawn no + taller than one lane needs); 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 @@ -53,7 +51,7 @@ from .menus import fit_cells, icon_lane, marked_label, section_heading from .pathgraph import PathLayer, render_path_graph from .pathline import path_line from .theme import glyph, name_style, snr_style -from .tui.render import render_hanging, render_to_ansi +from .tui.render import render_hanging, render_lines, render_to_ansi from .tui.screen import Screen from .widgets import ( NodeResolver, @@ -64,6 +62,8 @@ from .widgets import ( node_type_legend, route_graph_style, self_marker, + tab_air, + tab_strip, ) if TYPE_CHECKING: @@ -89,16 +89,22 @@ _GRAPH_ROWS = 3 #: colour, so the shape reads as one flat silhouette rather than two brightnesses. _AREA_TONE: RGB = (148, 163, 184) -#: The area drawing's cell box, a block of its own under the stats: the page's whole width -#: (proportions are kept, so a wider page is a bigger shape, never a padded one), rows in -#: step with the width — a quarter of the columns, which in braille dots is a box half as -#: tall as it is wide — with a floor on the rows, and a width below which the drawing is -#: dropped rather than squeezed into illegibility. Rows a flat shape leaves blank are -#: trimmed (:func:`_drawn_rows`), so a tall box costs a flat shape nothing. It used to ride -#: beside the stats in a third of the width, which left no room for a label on any pin. +#: The area drawing's cell box — the Area tab's whole stage: the page's width (proportions +#: are kept, so a wider page is a bigger shape, never a padded one) by every row the +#: viewport leaves under the strip and above the caption, with a floor on the rows (what +#: the first paint draws at, before the frame has said how tall the viewport is) and a +#: width below which the drawing is dropped rather than squeezed into illegibility. Rows a +#: flat shape leaves blank are trimmed (:func:`_drawn_rows`). It used to ride beside the +#: stats in a third of the width, which left no room for a label on any pin. _AREA_MIN_W = 16 _AREA_MIN_ROWS = 8 -_AREA_ROWS_PER_COL = 4 + +#: The page's tabs, in strip order. The Area tab is offered only for a walk with a +#: drawable shape; a lone tab collapses to a plain heading +#: (:func:`~meshterm.ui.widgets.tab_strip`), so a record with no ground to draw still +#: reads as the same page. +_TAB_STATS = "Stats" +_TAB_AREA = "Area" #: Dots kept clear inside the box's edges — wider on the sides than above and below, so a #: pin at the eastern or western extreme still has the cells its hash-byte label needs. _AREA_PAD_X = 8.0 @@ -200,11 +206,16 @@ class RecordScreen(Screen): reads *back*. It used to float as a 62-column card, which read as a question over the list rather than as the page it is. + A **tabbed stage**, the node page's shape (:func:`~meshterm.ui.widgets.tab_strip`, + ``Tab``/``Shift+Tab`` to switch): **Stats** is the record read — its stats, then the + route two ways — with the action rows at its foot; **Area** is the walk's ground, drawn + across the whole stage the viewport leaves under the strip, so the shape gets every row + and column the page has rather than the corner a stacked layout could spare it. The + Area tab is offered only when there is a shape to draw. + Every stat the walk was measured by — a reliability read from the far node's trace history, the far point named with the node it reached, the longest leg drawn as the - link it spanned (our own end on the app-wide ★); under them the walk's enclosed area - on a braille mini-map (north up, us at the origin, every positioned hop pinned in its - own name hue and labelled with its hash byte); below that, the walk on THE + link it spanned (our own end on the app-wide ★); the walk on THE route graph (us at both ends, relays wearing their map marker over a node-type key), 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 @@ -254,7 +265,6 @@ class RecordScreen(Screen): """ super().__init__() self.title = f"Record — {category.title} #{rank}" - self.footer_hint = "↑↓ move · PgUp/PgDn scroll · Enter select · Esc back" self._record = record self._category = category self._resolve = resolve @@ -268,15 +278,79 @@ class RecordScreen(Screen): self._actions = ("trace", "delete") self._index = 0 self._cursor: int | None = None - # The composed card above the action rows, per width (see render_body). - self._upper_cache: tuple[int, list[str]] | None = None - # The card opens at the top, reading down; the arrows drive (and follow) the action + self._tabs = [_TAB_STATS] + ([_TAB_AREA] if self._shape else []) + self._tab_index = 0 + # The Stats tab's composed page above the action rows, per width, and the Area + # tab's drawing, per (width, rows): both pure functions of the frozen record. + self._stats_cache: tuple[int, list[str]] | None = None + self._area_cache: tuple[tuple[int, int], list[str]] | None = None + # The page opens at the top, reading down; the arrows drive (and follow) the action # cursor, while PgUp/PgDn/Home/End scroll the body free of it (see cursor_line). self._follow = False + @property + def _tab(self) -> str: + """The tab filling the stage.""" + return self._tabs[self._tab_index] + + @property + def fkey_lane(self): + """The shared pager, dimmed where nothing scrolls, plus the tab switch on F3. + + The chip names the tab it would take you *to*, never the one you are on — the node + page's rule (see :attr:`~meshterm.ui.node_detail_screen.NodeDetailScreen.fkey_lane`). + A record with no drawable ground has one tab and nothing to switch, so the slot + stays empty; the Area tab is a picture sized to the viewport, so the pager is dim + there. + """ + from .tui.fkeys import FPair, default_lane + + lane = list(default_lane(nav=self._tab == _TAB_STATS and self.content_overflows)) + if len(self._tabs) >= 2: + lane[2] = FPair(self._tabs[(self._tab_index + 1) % len(self._tabs)], "tab") + return lane + + @property + def footer_hint(self) -> str: # type: ignore[override] + """The hint line: tab switch, then the Stats tab's move/scroll/select, Esc last. + + Each atom is gated on there being something for its key to do — the Area tab has + no cursor and never scrolls, so it names only the switch and Esc. + """ + parts: list[str] = [] + if len(self._tabs) >= 2: + parts.append("Tab/⇧Tab switch") + if self._tab == _TAB_STATS: + parts.append("↑↓ move") + if self.content_overflows: + parts.append("PgUp/PgDn scroll") + parts.append("Enter select") + parts.append("Esc back") + return " · ".join(parts) + + def _switch_tab(self, delta: int) -> None: + """Move the active tab; the page opens at the top, free of the cursor.""" + if len(self._tabs) < 2: + return + self._tab_index = (self._tab_index + delta) % len(self._tabs) + self._follow = False + self.scroll_to_top() + def handle(self, action: str, data: str = "") -> None: - """Move the action cursor, scroll the card, commit the selection, or dismiss.""" - if action == "up": + """Switch tab, move the action cursor, scroll the page, commit the selection, or leave. + + The Area tab is a picture sized to the viewport: nothing there moves, scrolls or + commits, so every key but the switch and Esc is inert on it. + """ + if action == "tab": + self._switch_tab(1) + elif action == "shift_tab": + self._switch_tab(-1) + elif action == "escape": + self.resolve(None) + elif self._tab != _TAB_STATS: + return + elif action == "up": self._follow = True # Both ends clamp rather than wrap — the app-wide rule for a row cursor: # a highlight that leaps end to end takes the results window with it. @@ -298,8 +372,6 @@ class RecordScreen(Screen): self.scroll_to_bottom() elif action == "enter": self.resolve(self._actions[self._index]) - elif action == "escape": - self.resolve(None) def cursor_line(self) -> int | None: """Keep the selected action visible while arrowing; scroll free once paging.""" @@ -437,22 +509,26 @@ class RecordScreen(Screen): lanes.append(self._lane("round trip", Text(f"{rtt:.0f} ms"))) return lanes - def _area_lines(self, width: int) -> list[str]: - """The walk's ground, as a block of its own between the stats and the route graph. + def _area_stage(self, width: int, budget: int) -> list[str]: + """The Area tab: the walk's ground, drawn to fill the rows the strip leaves. - Empty when there is no drawable shape, or the page is too narrow for one to be - legible. Otherwise a blank line, the section's heading (its note reading the - drawing back the way the route graph's does), and the drawing itself — the page's - whole width, rows in step with it, trimmed to the rows something landed on - (:func:`_drawn_rows`). It used to ride beside the stats in a third of the width, - which squeezed the shape and left no cell for a label on any pin. + ``budget`` is what the viewport leaves under the strip; the caption takes one row + and the drawing the rest — floored at :data:`_AREA_MIN_ROWS`, which is also what + the first paint draws at before the frame has said how tall the viewport is — then + trimmed to the rows something landed on (:func:`_drawn_rows`). A page too narrow to + draw on says so instead of squeezing the shape into illegibility. """ - if not self._shape or width < _AREA_MIN_W: - return [] - heading = body_heading("Area walked", "north up · labels = hash byte") - lines = ["", render_to_ansi(heading, width, no_wrap=True)] - rows = max(_AREA_MIN_ROWS, width // _AREA_ROWS_PER_COL) - lines.extend(_drawn_rows(self._shape_lines(width, rows))) + if width < _AREA_MIN_W: + note = Text("no room to draw the walk — widen the terminal", style="muted") + return [render_to_ansi(note, width, no_wrap=True)] + rows = max(_AREA_MIN_ROWS, budget - 1) + cached = self._area_cache + if cached is None or cached[0] != (width, rows): + cached = ((width, rows), _drawn_rows(self._shape_lines(width, rows))) + self._area_cache = cached + lines = list(cached[1]) + caption = Text("north up · labels = hash byte", style="faint") + lines.append(render_to_ansi(caption, width, no_wrap=True)) return lines def _shape_lines(self, cell_w: int, cell_h: int) -> list[str]: @@ -511,17 +587,30 @@ class RecordScreen(Screen): return canvas.to_ansi_lines() def render_body(self, width: int) -> list[str]: - """Stat lanes, the area drawing, the route graph, the route, provenance, actions.""" - # Everything above the action rows — the stat lanes, the area drawing, the - # route graph and the provenance — is a pure function of the frozen record - # and the width, so it is composed once per width; only the cursor-bearing - # action rows below re-render per repaint. - cached = self._upper_cache - if cached is not None and cached[0] == width: - lines = list(cached[1]) - else: - lines = self._upper_lines(width) - self._upper_cache = (width, list(lines)) + """The tab strip, then the active tab's stage. + + The Stats tab is the record read — stat lanes, the route graph, the route — and + closes on the action rows; it scrolls when it outgrows the viewport. The Area tab + is the ground drawing sized to the rows the strip leaves, so it never does. + Everything above the action rows is a pure function of the frozen record and the + width (and, for the drawing, the rows), so it is composed once and cached; only the + cursor-bearing action rows re-render per repaint. + """ + # The pinned chrome every tab opens under: the strip in its air (see widgets.tab_air). + lines: list[str] = [""] * tab_air() + lines.extend(render_lines(tab_strip(self._tabs, self._tab_index, width), width)) + lines.extend([""] * tab_air()) + if self._tab == _TAB_AREA: + self._cursor = None + lines.extend(self._area_stage(width, self._scroll_viewport - len(lines))) + self._scroll_total = max(1, len(lines)) + return lines + + cached = self._stats_cache + if cached is None or cached[0] != width: + cached = (width, self._stats_lines(width)) + self._stats_cache = cached + lines.extend(cached[1]) lines.append("") self._cursor = None @@ -549,16 +638,16 @@ class RecordScreen(Screen): self._scroll_total = max(1, len(lines)) return lines - def _upper_lines(self, width: int) -> list[str]: - """Compose the record card above the action rows (memoized per width).""" + def _stats_lines(self, width: int) -> list[str]: + """The Stats tab above its action rows: the stat lanes, then the route two ways. + + The tab is the page's title, so the stats open directly under the strip; the route + block is set apart under a body heading (THE form, widgets.body_heading) whose note + reads the graph's labels back. + """ record = self._record lines: list[str] = [] - # Three sections, each under a body heading (THE form, widgets.body_heading): the - # stats, the ground the walk covered, and the route two ways. The headings are what - # lets a page this tall be skimmed — a reader after the route scrolls to "Route" - # rather than reading down through the drawing to find it. - lines.append(render_to_ansi(body_heading("Stats"), width, no_wrap=True)) # The record's identity leads its stats: the spec that was walked and when it was # set, then what the walk measured. spec = Text(record.spec, style="brand") @@ -568,7 +657,6 @@ class RecordScreen(Screen): when.append(f" · MeshTerm {record.app_version}", style="muted") lines.append(render_to_ansi(self._lane("recorded", when), width, no_wrap=True)) lines.extend(render_to_ansi(lane, width, no_wrap=True) for lane in self._stat_lanes()) - lines.extend(self._area_lines(width)) lines.append("") heading = body_heading("Route", "you → … → you · labels = hash byte") diff --git a/meshterm/ui/widgets.py b/meshterm/ui/widgets.py index 07d16cc..1a02df4 100644 --- a/meshterm/ui/widgets.py +++ b/meshterm/ui/widgets.py @@ -1165,6 +1165,30 @@ def node_type_legend(indent: str = "") -> Text: return legend +#: Blank rows of air around a tab strip — one above and one under on the desktop; none +#: on the PicoCalc (JP, 2026-08-08), where rows are the scarce resource and both lines go +#: to the stage instead (the route graph's ceiling, the location preview's growth room). +#: Rides the same frugality signal as the borderless frame, and is bound at +#: platform-switch time like every platform-derived constant — never branched per paint. +_TAB_AIR = 1 + + +@on_platform +def _bind_tab_air(platform: Platform) -> None: + """Bind the strip's air to the platform (runs now and on every switch).""" + global _TAB_AIR + _TAB_AIR = 1 if platform.frame_border else 0 + + +def tab_air() -> int: + """Blank rows a tabbed page spends above its strip and under it (see :data:`_TAB_AIR`). + + Read by every screen that draws :func:`tab_strip` — the node page, a record — so the + strip sits in the same air everywhere, and loses it on the same platform. + """ + return _TAB_AIR + + def tab_strip(labels: Sequence[str], active: int, width: int) -> Group: """A boxed tab strip: every tab always boxed on top, the active one open into the page. diff --git a/tests/test_gallery.py b/tests/test_gallery.py index bf7baed..98d3519 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -83,7 +83,7 @@ from meshterm.ui.node_detail_screen import NodeDetailScreen, _Action, _RoutesVie from meshterm.ui.packet_viewer import PacketEntry, PacketViewer from meshterm.ui.path_composer import PathComposerScreen from meshterm.ui.preferences import _menu_items as _preference_items -from meshterm.ui.records_screen import RecordScreen +from meshterm.ui.records_screen import RecordScreen, WalkVertex from meshterm.ui.remote_cli import RemoteCliScreen from meshterm.ui.repeater_admin import AdminMenu from meshterm.ui.repeater_admin import _menu_items as _admin_menu_items @@ -94,7 +94,13 @@ from meshterm.ui.tui import Screen, SelectScreen, fkeys, frame from meshterm.ui.tui.prompt import CountdownDialog from meshterm.ui.tx_screen import TxSweepScreen from meshterm.ui.walk_screen import WalkScreen -from meshterm.ui.widgets import _NODE_GLYPHS, ContactsSort, highlighted_hash +from meshterm.ui.widgets import ( + _NODE_GLYPHS, + ContactsSort, + highlighted_hash, + node_marker, + self_marker, +) from tests.conftest import plain as _plain _HUB_KEY = "3d63c6429436" + "0" * 52 @@ -673,6 +679,31 @@ def _record_dialog(cols: int, rows: int) -> Screen: ) +def _record_area(cols: int, rows: int) -> Screen: + """A record with positioned hops, turned to its Area tab: the drawing at the stage's size.""" + record = _record() + sglyph, scolor = self_marker() + hglyph, hcolor = node_marker(NODE_TYPE_REPEATER) + nglyph, ncolor = node_marker(1) + screen = RecordScreen( + record, + CATEGORY_BY_ID[record.category], + 1, + resolve=lambda h: {"3d63c6429436": "Hilltop-Repeater", "f2c24f54551e": "Alice"}.get(h, h), + device_label="Homestead", + device_hash=None, + far_label="Alice", + shape=[ + WalkVertex(0.0, 0.0, sglyph, scolor, True), + WalkVertex(4.0, 1.5, hglyph, hcolor, False, label="3d"), + WalkVertex(2.5, 3.0, nglyph, ncolor, False, label="f2"), + ], + ) + screen.note_metrics(rows, rows) + screen.handle("tab") + return screen + + def _packet_viewer(cols: int, rows: int) -> Screen: entry = PacketEntry( when=utcnow(), @@ -896,6 +927,7 @@ _ENTRIES: list[_Entry] = [ _Entry("path_composer", _path_composer), _Entry("courier_outbox", _courier_outbox), _Entry("record_dialog", _record_dialog), + _Entry("record_area", _record_area), _Entry("packet_viewer", _packet_viewer), _Entry("trace", _trace), _Entry("tx_sweep", _tx_sweep), diff --git a/tests/test_records_screen.py b/tests/test_records_screen.py index 5c5d7fb..8777684 100644 --- a/tests/test_records_screen.py +++ b/tests/test_records_screen.py @@ -300,64 +300,116 @@ def _is_braille(ch: str) -> bool: return chr(0x2800) <= ch <= chr(0x28FF) -def test_record_draws_the_enclosed_area_under_the_stats_with_hash_byte_pins() -> None: - """A drawable walk pins its ground under the stats, each hop labelled with its hash byte. +def _strip_end(lines: list[str]) -> int: + """The index just past the tab strip's rows. + + A two-tab strip closes on its bottom rule — the one row that turns up into the active + tab's wall (``╯``); a lone tab collapses to a ``── Stats ──`` heading, one row. + """ + for i, line in enumerate(lines): + if "╯" in line or "── " in line: + return i + 1 + raise AssertionError(f"no tab strip in {lines[:5]}") + + +def test_record_opens_on_the_stats_tab_with_the_area_tab_beside_it() -> None: + """A drawable walk gets two tabs — Stats, then Area — and opens on Stats. + + The node page's shape: the strip is the page's title, so the stats open directly + under it (identity first), the route follows under its own heading, and nothing of the + ground is drawn on this tab — the drawing has a tab of its own. + """ + screen = _dialog(_record(), far_label="Far", shape=_loop_shape()) + lines = _plain(screen.render_body(58)).splitlines() + strip = "\n".join(lines[: _strip_end(lines)]) + assert "Stats" in strip and "Area" in strip, "both tabs boxed in the strip" + body = [line for line in lines[_strip_end(lines) :] if line] + assert body[0].startswith("spec") and body[1].startswith("recorded") # identity first + assert body[2].startswith("score") + assert any(line.startswith("Route") for line in body) # the route, under its heading + assert "Trace this path" in "\n".join(body) # the actions close the Stats tab + assert not any( + _is_braille(ch) + for line in body[: body.index("Route · you → … → you · labels = hash byte")] + for ch in line + ) + assert "Far" in "\n".join(body) # the far-point name is a stat, not a drawing label + + +def test_record_area_tab_fills_the_stage_with_hash_byte_pins() -> None: + """Tab turns to the Area tab: the ground, pinned and labelled, sized to the viewport. The drawing used to ride beside the stats in a third of the width, unlabelled — there - was no cell for a label. It is a block of its own now, between the stats and the route - graph, so a pin and the graph node under it can be matched by their shared byte. + was no cell for a label. It has a tab of its own now and spends every row the strip + leaves, so a pin and the graph node on the Stats tab can be matched by their shared + byte, and a taller terminal is a bigger shape. """ - lines = _plain( - _dialog(_record(), far_label="Far", shape=_loop_shape()).render_body(58) - ).splitlines() - first_blank = lines.index("") - stats = lines[:first_blank] - assert stats[0] == "Stats" # under its own heading… - assert stats[1].startswith("spec") and stats[2].startswith("recorded") # …identity first - assert stats[3].startswith("score") - assert not any(_is_braille(ch) for ch in "\n".join(stats)), "no drawing beside the lanes" - assert "Far" in "\n".join(stats) # the far-point name is a stat, not a drawing label - - area = lines[first_blank + 1 : lines.index("", first_blank + 1)] - assert area[0].startswith("Area walked") and "labels = hash byte" in area[0] # heading - block = "\n".join(area[1:]) + screen = _dialog(_record(), far_label="Far", shape=_loop_shape()) + screen.note_metrics(40, 24) + screen.handle("tab") + lines = _plain(screen.render_body(58)).splitlines() + stage = [line for line in lines[_strip_end(lines) :] if line] + block = "\n".join(stage) assert any(_is_braille(ch) for ch in block) # the area, in braille assert "★" in block # us pinned at the origin assert "▲" in block # the repeater vertex, in its map glyph assert HUB_ID[:2] in block and FAR_ID[:2] in block # each hop's hash byte beside its pin - assert len(area) - 1 >= 8, "the drawing spends the rows it is given" - # The three section headings, in reading order: each opens its block right after a - # blank line, so a page this tall can be skimmed by its landmarks. - at = { - name: next(i for i, line in enumerate(lines) if line.split(" · ")[0] == name) - for name in ("Stats", "Area walked", "Route") - } - assert at["Stats"] < at["Area walked"] < at["Route"] - assert all(lines[i - 1] == "" for name, i in at.items() if name != "Stats") - assert at["Route"] > lines.index(area[-1]), "the route graph follows the drawing" + assert stage[-1] == "north up · labels = hash byte" # the caption reads the drawing back + assert "Trace this path" not in block # the actions belong to the Stats tab + assert len(lines) <= 24, "the Area tab never outgrows the viewport" + + screen.note_metrics(40, 40) + taller = _plain(screen.render_body(58)).splitlines() + assert len(taller) > len(lines), "a taller viewport is a taller drawing" + + screen.handle("tab") # …and back round to Stats + assert "Trace this path" in _plain(screen.render_body(58)) def test_record_area_drawing_spends_the_width_it_is_given() -> None: """The drawing is as wide as the page: a wider page is a bigger, not a padded, shape.""" - narrow = _plain(_dialog(_record(), shape=_loop_shape()).render_body(40)).splitlines() - wide = _plain(_dialog(_record(), shape=_loop_shape()).render_body(72)).splitlines() - def drawn_width(lines: list[str]) -> int: - start = next(i for i, line in enumerate(lines) if line.startswith("Area walked")) + 1 - block = lines[start : lines.index("", start)] - return max(len(line.rstrip()) for line in block) + def drawn_width(width: int) -> int: + screen = _dialog(_record(), shape=_loop_shape()) + # Rows enough that the shape's proportions bind on the width, not the height. + screen.note_metrics(40, 40) + screen.handle("tab") + lines = _plain(screen.render_body(width)).splitlines() + return max(len(line.rstrip()) for line in lines[_strip_end(lines) : -1]) - assert drawn_width(wide) > drawn_width(narrow) + assert drawn_width(72) > drawn_width(40) -def test_record_drops_the_area_drawing_when_too_narrow() -> None: - """Below the drawing's floor the block is left out; the far name stays in the stats.""" - body = _plain(_dialog(_record(), far_label="Far", shape=_loop_shape()).render_body(14)) - lines = body.splitlines() - assert "Area walked" not in body # no drawing, no heading for one - # The stats run straight into the blank line before the route graph; nothing pinned. - assert "★" not in "\n".join(lines[: lines.index("")]) - assert "Far" in body # the far-point name survives the drop +def test_record_area_tab_says_so_when_too_narrow_to_draw() -> None: + """Below the drawing's floor the Area tab says why it is empty instead of squeezing.""" + screen = _dialog(_record(), far_label="Far", shape=_loop_shape()) + screen.handle("tab") + body = _plain(screen.render_body(12)) + assert not any(_is_braille(ch) for ch in body) + assert "no room" in body # cut at the edge like any row, but there + + +def test_record_without_a_shape_has_one_tab_and_nothing_to_switch() -> None: + """No positioned hops, no Area tab: the strip collapses to a heading and Tab is inert.""" + screen = _dialog(_record()) + lines = _plain(screen.render_body(60)).splitlines() + assert "── Stats ──" in "\n".join(lines[: _strip_end(lines)]) + assert "Area" not in "\n".join(lines) + assert "Tab" not in screen.footer_hint + screen.handle("tab") + assert screen._tab_index == 0 + + +def test_record_footer_names_only_what_each_tab_answers() -> None: + """The Stats tab hints the cursor keys; the Area tab, a picture, only the switch and Esc.""" + screen = _dialog(_record(), shape=_loop_shape()) + assert screen.footer_hint == "Tab/⇧Tab switch · ↑↓ move · Enter select · Esc back" + screen.note_metrics(40, 10) # taller than the viewport: the pager earns its atom + assert "PgUp/PgDn scroll" in screen.footer_hint + screen.handle("tab") + assert screen.footer_hint == "Tab/⇧Tab switch · Esc back" + screen.handle("down") # inert on the Area tab + assert screen._index == 0 def test_record_dialog_scrolls_free_of_the_action_cursor() -> None: