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:
1 parent
197af90c8e
commit
ea8ae61812
5 files changed
+304
-121
No files matched your search
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user