diff --git a/meshterm/ui/message_paths_screen.py b/meshterm/ui/message_paths_screen.py index 5e7dc4e..3736f76 100644 --- a/meshterm/ui/message_paths_screen.py +++ b/meshterm/ui/message_paths_screen.py @@ -75,6 +75,7 @@ from .widgets import ( revisit_note, route_graph_style, scope_text, + short_frame, ) if TYPE_CHECKING: @@ -322,20 +323,25 @@ class MessagePathsScreen(Screen): # No blank line above the graph: its canvas already opens with air over the # topmost lane, so a spacer here would read as two rows of margin. revisit = self._revisit_line(width) - # The fan's own trailing chrome (caption, node-type key, the revisit note) plus the - # blank that sets the list apart — all struck from the budget before the ceiling is. - chrome = 2 + len(revisit) + 1 - room = viewport - len(lines) - chrome - min(sum(heights), _LIST_MIN_LINES) - graph = self._graph_lines(width, max(_GRAPH_MIN_ROWS, min(_GRAPH_MAX_ROWS, room))) - if graph: - lines.extend(graph) + # The fan's caption and node-type key, which a short frame leaves out: the marks + # mean one thing app-wide, and the rows go to the list (see widgets.short_frame). + key: list[str] = [] + if not short_frame(viewport): far = "you" if not self._destination else self._destination caption = Text( f"origin → {far} · white = selected path · labels = hash byte", style="faint", ) - lines.append(render_to_ansi(caption, width, no_wrap=True)) - lines.extend(render_lines(node_type_legend(width=width), width, no_wrap=True)) + key.append(render_to_ansi(caption, width, no_wrap=True)) + key.extend(render_lines(node_type_legend(width=width), width, no_wrap=True)) + # The fan's own trailing chrome (that key, the revisit note) plus the blank that + # sets the list apart — all struck from the budget before the ceiling is. + chrome = len(key) + len(revisit) + 1 + room = viewport - len(lines) - chrome - min(sum(heights), _LIST_MIN_LINES) + graph = self._graph_lines(width, max(_GRAPH_MIN_ROWS, min(_GRAPH_MAX_ROWS, room))) + if graph: + lines.extend(graph) + lines.extend(key) lines.extend(revisit) lines.append("") diff --git a/meshterm/ui/node_detail_screen.py b/meshterm/ui/node_detail_screen.py index d9f40e9..c905fbd 100644 --- a/meshterm/ui/node_detail_screen.py +++ b/meshterm/ui/node_detail_screen.py @@ -131,8 +131,8 @@ from .widgets import ( format_ago, highlighted_hash, node_type_legend, + short_frame, tab_air, - tab_compact, tab_strip, ) @@ -767,7 +767,7 @@ class NodeDetailScreen(Screen): [t.name for t in self._tabs], self._tab_index, width, - compact=tab_compact(viewport), + compact=short_frame(viewport), ), width, no_wrap=True, diff --git a/meshterm/ui/records_screen.py b/meshterm/ui/records_screen.py index 60e690c..d9784c0 100644 --- a/meshterm/ui/records_screen.py +++ b/meshterm/ui/records_screen.py @@ -62,8 +62,8 @@ from .widgets import ( node_type_legend, route_graph_style, self_marker, + short_frame, tab_air, - tab_compact, tab_strip, ) @@ -649,7 +649,7 @@ class RecordScreen(Screen): lines: list[str] = [render_to_ansi(self._header(), width, no_wrap=True)] lines.extend([""] * tab_air()) strip = tab_strip( - self._tabs, self._tab_index, width, compact=tab_compact(self._scroll_viewport) + self._tabs, self._tab_index, width, compact=short_frame(self._scroll_viewport) ) lines.extend(render_lines(strip, width)) lines.extend([""] * tab_air()) diff --git a/meshterm/ui/walk_screen.py b/meshterm/ui/walk_screen.py index afc450f..1f41f50 100644 --- a/meshterm/ui/walk_screen.py +++ b/meshterm/ui/walk_screen.py @@ -76,7 +76,7 @@ from .theme import mark_rgb, name_style, snr_style from .trace_screen import snr_bar from .tui.render import crop_cells, query_line, render_to_ansi from .tui.screen import ListWindow, Screen -from .widgets import DEFAULT_GLYPH, NODE_GLYPHS, format_age, highlighted_hash +from .widgets import DEFAULT_GLYPH, NODE_GLYPHS, format_age, highlighted_hash, short_frame if TYPE_CHECKING: from ..context import AppContext @@ -557,14 +557,18 @@ class WalkScreen(Screen): viewport = self._scroll_viewport # recorded by the frame before this render header = self._header_lines(width, depths) - chrome = len(header) + 3 + self._query_row() # legend, blank, list heading (+find echo) + # The legend and the blank under it go on a short frame, where the rows are the + # list's (see widgets.short_frame); the list's heading stays either way. + keyed = not short_frame(viewport) + chrome = len(header) + (3 if keyed else 1) + self._query_row() # (+find echo) canvas_h = self._canvas_height(viewport, chrome) list_win = max(1, viewport - chrome - canvas_h) lines: list[str] = list(header) lines.extend(self._canvas_lines(width, canvas_h, selected)) - lines.append(render_to_ansi(self._legend(), width, no_wrap=True)) - lines.append("") + if keyed: + lines.append(render_to_ansi(self._legend(), width, no_wrap=True)) + lines.append("") lines.extend(self._list_lines(width, rows, depths, list_win)) self._scroll_total = max(1, len(lines)) return lines diff --git a/meshterm/ui/widgets.py b/meshterm/ui/widgets.py index e194d6b..e143eea 100644 --- a/meshterm/ui/widgets.py +++ b/meshterm/ui/widgets.py @@ -1247,17 +1247,19 @@ def tab_air() -> int: return _TAB_AIR -#: The viewport, in rows, below which a tabbed page draws its strip on one row (see -#: :func:`tab_strip`'s ``compact``). The boxed strip costs three rows and its air; on the -#: Cardputer's 11-row viewport that was the difference between a page's actions on screen -#: and below the fold. Read from the viewport rather than the platform, so a desktop -#: terminal dragged short gets the same answer the handheld does. -COMPACT_TABS_BELOW = 16 +#: The viewport, in rows, below which a frame is *short* and spends its rows on content: +#: a tabbed page draws its strip on one row (see :func:`tab_strip`'s ``compact``), and a +#: graph's caption and node-type key step aside so the list under it gets the rows. The +#: boxed strip alone costs three; on the Cardputer's 11-row viewport that was the +#: difference between a page's actions on screen and below the fold. Read from the +#: viewport rather than the platform, so a desktop terminal dragged short gets the same +#: answer the handheld does. +SHORT_FRAME_BELOW = 16 -def tab_compact(viewport: int) -> bool: - """Whether a page with ``viewport`` rows draws its tab strip compact (one row).""" - return viewport < COMPACT_TABS_BELOW +def short_frame(viewport: int) -> bool: + """Whether a page with ``viewport`` rows is short (see :data:`SHORT_FRAME_BELOW`).""" + return viewport < SHORT_FRAME_BELOW def tab_strip(labels: Sequence[str], active: int, width: int, *, compact: bool = False) -> Group: @@ -1290,7 +1292,7 @@ def tab_strip(labels: Sequence[str], active: int, width: int, *, compact: bool = owning screen switches the active index (``Tab``/``Shift+Tab``); the strip itself is pure presentation. - Where rows are scarcer than that (``compact``, see :func:`tab_compact`) the same strip is + Where rows are scarcer than that (``compact``, see :func:`short_frame`) the same strip is drawn on its own bottom rule, in one row: ``──┤ Info ├── Routes ──────``. The active tab stands on the rule between tees, lit; an inactive one is its label on the rule, faint. Every tab is the same width in either state, so switching still never moves a label. diff --git a/tests/test_widgets.py b/tests/test_widgets.py index ccbb0fb..f9c5372 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -290,10 +290,10 @@ def test_every_advert_type_has_one_name() -> None: def test_a_compact_tab_strip_is_one_row_and_never_moves_a_label() -> None: - """Below COMPACT_TABS_BELOW rows the strip draws on its rule, every tab a fixed width.""" - from meshterm.ui.widgets import COMPACT_TABS_BELOW, tab_compact, tab_strip + """Below SHORT_FRAME_BELOW rows the strip draws on its rule, every tab a fixed width.""" + from meshterm.ui.widgets import SHORT_FRAME_BELOW, short_frame, tab_strip - assert tab_compact(COMPACT_TABS_BELOW - 1) and not tab_compact(COMPACT_TABS_BELOW) + assert short_frame(SHORT_FRAME_BELOW - 1) and not short_frame(SHORT_FRAME_BELOW) rows = [tab_strip(["Info", "Routes", "Area"], i, 40, compact=True) for i in range(3)] plains = [group.renderables[0].plain for group in rows] assert all(len(group.renderables) == 1 for group in rows)