A record is a tabbed page: Stats, then the walk's ground on an Area tab of its own

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 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5.1 committed 2026-09-12 04:00:22 -04:00
1 parent 197af90c8e
commit ea8ae61812
5 files changed
+304 -121

No files matched your search

+6 -19
View File
@@ -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))
+145 -57
View File
@@ -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")
+24
View File
@@ -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.
+34 -2
View File
@@ -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),
+95 -43
View File
@@ -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: