diff --git a/CLAUDE.md b/CLAUDE.md index 33a0879..abf5ae9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,12 @@ Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now a - A row that opens further prompts ends with `…`; a row that acts immediately doesn't. - Empty states are lowercase muted, optionally `— explanation`, never parenthesized. - Body section headings inside screens: accent title, optional muted ` · note`. +- A find-as-you-type screen echoes its live query as `/query` in `warn`, on its own body + line **directly above whatever the query narrows** — `render.query_line` is the one + definition (select list, path composer, map, mesh walk). A screen that *also* carries + the query in its footer hint draws the body line only where the footer isn't drawn + (`Platform.footer_fkeys`), never both: the query must be visible on every platform, and + twice on none. ### Titles diff --git a/meshterm/ui/map_screen.py b/meshterm/ui/map_screen.py index ad64724..2347e21 100644 --- a/meshterm/ui/map_screen.py +++ b/meshterm/ui/map_screen.py @@ -30,8 +30,10 @@ from typing import TYPE_CHECKING, Callable, Optional from ..core.geo import DEFAULT_VIEW_FRACTION, EARTH_RADIUS_KM, Viewport, clamp_lat from ..core.mvt import Layer +from ..platforms import get_platform from ..services.basemap import BasemapSource from .map_render import MapMarker, render_map +from .tui.render import query_line from .tui.screen import Screen if TYPE_CHECKING: @@ -205,11 +207,29 @@ class MapScreen(Screen): self._needs_scrub = False return 2 # the panel's right padding cell and its right border cell + def _query_row(self) -> bool: + """Whether this paint spends a body row echoing the find query above the canvas. + + Only where the footer isn't drawn (:attr:`~meshterm.platforms.Platform.footer_fkeys`): + there the hint line carrying the query never reaches the screen, so without this row + the map would silently filter itself while the reader has no idea what they typed. + On the desktop the footer already shows it and the canvas keeps the whole body. + """ + return bool(self._filter) and get_platform().footer_fkeys + def render_body(self, width: int) -> list[str]: - """Build (or resize) the viewport, ensure its tiles, and render the frame.""" + """Build (or resize) the viewport, ensure its tiles, and render the frame. + + The canvas is sized to whatever the body has left after the find echo (see + :meth:`_query_row`), so beginning a find costs the map one row of ground rather + than pushing its last row out of the viewport. The viewport is rebuilt at the new + height by the ordinary resize path below — centre and zoom are preserved, so the + view doesn't jump, it just loses (and later regains) a strip along the bottom. + """ _, cell_h = self._session.base_body_size() + head = [query_line(self._filter, width)] if self._query_row() else [] cell_w = width - dot_w, dot_h = cell_w * 2, cell_h * 4 + dot_w, dot_h = cell_w * 2, max(1, cell_h - len(head)) * 4 if self._viewport is None: self._viewport = self._initial_viewport(dot_w, dot_h) @@ -222,7 +242,7 @@ class MapScreen(Screen): self.title = self._title(self._viewport) self._persist() tiles = {t: self._tiles.get(t) for t in self._viewport.tiles(self._max_tile_zoom)} - return render_map(self._viewport, tiles, self._markers, find=self._filter) + return head + render_map(self._viewport, tiles, self._markers, find=self._filter) def _initial_viewport(self, dot_w: int, dot_h: int) -> Viewport: """Restore the saved view (clamped to sane bounds) or frame the nodes' dense core. diff --git a/meshterm/ui/path_composer.py b/meshterm/ui/path_composer.py index bd22b03..4b206dd 100644 --- a/meshterm/ui/path_composer.py +++ b/meshterm/ui/path_composer.py @@ -71,7 +71,7 @@ from ..services.topology import ( HopSuggestion, Link, MeshTopology, _is_hex, render_custom_spec, render_forced_spec, ) from .theme import snr_style -from .tui.render import render_lines, render_to_ansi +from .tui.render import query_line, render_lines, render_to_ansi from .tui.screen import ListWindow, Screen from .pathline import PathLine, path_line from .widgets import NodeResolver, _age_seconds, _format_age, _identity, path_text @@ -593,7 +593,7 @@ class PathComposerScreen(Screen): lines.extend(render_lines(Text(_NOT_A_TRAIL, style="warn"), width)) lines.append("") if self._entry: - lines.append(render_to_ansi(Text(f"/{self._entry}", style="warn"), width)) + lines.append(query_line(self._entry, width)) else: heading = Text("Next hop from ", style="muted") heading.append_text(self._node_text(self._anchor())) diff --git a/meshterm/ui/tui/render.py b/meshterm/ui/tui/render.py index 09bff06..6f6f307 100644 --- a/meshterm/ui/tui/render.py +++ b/meshterm/ui/tui/render.py @@ -184,6 +184,31 @@ def render_lines(renderable: RenderableType, width: int, *, no_wrap: bool = Fals return lines +def query_line(query: str, width: int) -> str: + """The live find/filter query echoed as one body line: ``/yul``, warn-styled. + + THE way a find-as-you-type screen shows what has been typed. Every such screen draws + this same line above whatever the query narrows — the select list above its rows, the + path composer above its suggestions, the map above its canvas, the walk above its link + list — so the text you are typing is always in the same shape, in the same place, in + the one colour nothing else in a body uses. + + The map and the walk *also* echo the query in their footer hint, with its editing keys + beside it. That line is not drawn on a platform whose footer is the F-key lane (see + :attr:`~meshterm.platforms.Platform.footer_fkeys`), which is exactly why those two + call this: without it, the query would be invisible on the device while you typed it. + They gate the call on that flag rather than drawing the line twice on the desktop. + + Args: + query: The current query (callers only call this when it is non-empty). + width: Inner content width in columns. + + Returns: + One rendered ANSI line. + """ + return render_to_ansi(Text(f"/{query}", style="warn"), width, no_wrap=True) + + def right_aligned_tail(body: Text, tail: Text, width: int) -> Text: """Lay ``body`` out with ``tail`` pinned to the right edge of its last line. diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index 908f3c0..8eb4b71 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -15,7 +15,7 @@ from typing import Any, Callable, Optional, Union from rich.cells import cell_len from rich.text import Text -from .render import crop_cells, render_lines, render_to_ansi +from .render import crop_cells, query_line, render_lines, render_to_ansi from .screen import Screen @@ -521,7 +521,7 @@ class SelectScreen(Screen): self._pinned_header = None block: Optional[list[str]] = None # the heading block still taking rows, if any if self._filter: - lines.append(render_to_ansi(Text(f"/{self._filter}", style="warn"), width)) + lines.append(query_line(self._filter, width)) # A row's detail line (see Choice.detail) can make it two lines tall, so the cursor # is tracked inline as rows are drawn rather than derived from the row index. cursor_at: Optional[int] = None diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 44b70ba..7ef8cad 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -1167,7 +1167,10 @@ class TuiSession: Mirrors the layout math in :func:`~meshterm.ui.tui.frame.compose_base` so a full-screen screen (e.g. the map) can size its own content to fill the frame exactly, - without waiting a repaint to learn its height. + without waiting a repaint to learn its height. *Both* of that function's branches: + a borderless platform swaps the panel's two border rows and four padding columns for + a single title-bar row, so a screen sizing itself against the bordered math there + would leave a row of the frame it was handed permanently blank. Returns: The inner content width and the body viewport height, both in character cells. @@ -1176,8 +1179,10 @@ class TuiSession: cols, rows = self._size() header_h = len(render_lines(self._header(cols), cols, no_wrap=True)) - viewport = max(1, rows - header_h - 1 - 2) # minus footer(1) and panel border(2) - return cols - 4, viewport + if get_platform().frame_border: + # minus footer(1) and panel border(2); the border eats 4 columns too + return cols - 4, max(1, rows - header_h - 1 - 2) + return cols, max(1, rows - header_h - 1 - 1) # minus footer(1) and title bar(1) def _base_index(self) -> int: """Stack index of the full-frame background screen. diff --git a/meshterm/ui/walk_screen.py b/meshterm/ui/walk_screen.py index fe27541..137532e 100644 --- a/meshterm/ui/walk_screen.py +++ b/meshterm/ui/walk_screen.py @@ -57,6 +57,7 @@ from typing import TYPE_CHECKING, Optional from rich.cells import cell_len from rich.text import Text +from ..platforms import get_platform from ..core.models import ( NODE_TYPE_REPEATER, Contact, @@ -69,7 +70,7 @@ from .menus import fit_cells from .pathline import ELIDE_HEAD, PathHop, PathLine from .theme import mark_rgb, name_style, node_style, snr_style from .trace_screen import snr_bar -from .tui.render import render_to_ansi +from .tui.render import query_line, render_to_ansi from .tui.screen import ListWindow, Screen from .widgets import _format_age, highlighted_hash @@ -458,7 +459,7 @@ class WalkScreen(Screen): viewport = self._scroll_viewport # recorded by the frame before this render header = self._header_lines(width, depths) - chrome = len(header) + 3 # legend, blank, list heading + chrome = len(header) + 3 + self._query_row() # legend, blank, list heading (+find echo) canvas_h = self._canvas_height(viewport, chrome) list_win = max(1, viewport - chrome - canvas_h) @@ -470,6 +471,17 @@ class WalkScreen(Screen): self._scroll_total = max(1, len(lines)) return lines + def _query_row(self) -> int: + """Whether this paint spends a list row echoing the find query (1) or not (0). + + Only where the footer isn't drawn (:attr:`~meshterm.platforms.Platform.footer_fkeys`): + there the hint line carrying the query never reaches the screen, so without this row + the list would narrow to matches with no sign of what was typed to narrow it. The row + sits directly above the ``Matches`` heading — above what it narrows, as on every other + find-as-you-type screen — and the list, not the canvas, cedes the line for it. + """ + return 1 if self._filter and get_platform().footer_fkeys else 0 + def _canvas_height(self, viewport: int, chrome: int) -> int: """Rows the canvas takes: the majority of the screen, ceded where pointless. @@ -890,6 +902,8 @@ class WalkScreen(Screen): window's row count becomes the PgUp/PgDn stride. """ out: list[str] = [] + if self._query_row(): + out.append(query_line(self._filter, width)) if self._filter: heading = Text("Matches", style="accent") heading.append(" · nearest first · Enter focuses", style="muted") diff --git a/tests/test_map.py b/tests/test_map.py index b70fef1..7819d2c 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -1179,6 +1179,44 @@ def test_map_locate_recenters_on_our_own_node_at_the_current_zoom() -> None: assert away.fkey_lane[1].enabled is False and away._viewport is before +def test_map_echoes_the_find_query_in_the_body_only_where_the_footer_is_gone() -> None: + """The PicoCalc draws no hint line, so the query it carries moves into the body.""" + from meshterm.platforms import PICOCALC, REGULAR, set_platform + from meshterm.ui.map_render import MapMarker + from meshterm.ui.map_screen import MapScreen + + markers = [MapMarker("YUL-Cartierville", 45.53, -73.71, is_repeater=True)] + + try: + set_platform(REGULAR) + desktop = MapScreen(_StubSession(72, 20), markers, _StubSource(), 14) + assert len(desktop.render_body(72)) == 20 + for ch in "yul": + desktop.handle("text", ch) + # Unchanged: the footer already shows it, so the canvas keeps the whole body. + assert "/yul" not in _plain(desktop.render_body(72)) + assert len(desktop.render_body(72)) == 20 + assert "find: yul" in desktop.footer_hint + + set_platform(PICOCALC) + device = MapScreen(_StubSession(53, 23), markers, _StubSource(), 14) + assert len(device.render_body(53)) == 23 + for ch in "yul": + device.handle("text", ch) + lines = device.render_body(53) + # The echo takes the first row and the canvas gives up exactly one, so the body + # still fills the frame it was handed rather than overflowing it. + assert _plain(lines[0]).strip() == "/yul" + assert len(lines) == 23 + # Clearing the find hands the row back. + for _ in "yul": + device.handle("backspace") + assert len(device.render_body(53)) == 23 + assert "/" not in _plain(device.render_body(53)) + finally: + set_platform(REGULAR) + + def test_map_frame_chip_lights_only_with_matches_to_frame() -> None: """``Frame`` is the find's Enter under another name — dim, and inert, without a query.""" from meshterm.ui.map_render import MapMarker diff --git a/tests/test_walk.py b/tests/test_walk.py index e108f04..4f6bd3e 100644 --- a/tests/test_walk.py +++ b/tests/test_walk.py @@ -315,6 +315,39 @@ def test_walk_home_refocuses_us() -> None: assert screen._trail == [topo.self_id] +def test_walk_echoes_the_find_query_above_the_matches_it_narrows() -> None: + """The PicoCalc draws no hint line, so the query it carries moves into the body.""" + from meshterm.platforms import PICOCALC, REGULAR, set_platform + + try: + set_platform(REGULAR) + desktop = _screen(_topo()) + desktop.render_body(80) + for ch in "al": + desktop.handle("text", ch) + body = _plain(desktop.render_body(80)) + assert "/al" not in body and "find: al" in desktop.footer_hint + + set_platform(PICOCALC) + device = _screen(_topo()) + device.render_body(53) + for ch in "al": + device.handle("text", ch) + lines = [_plain(line) for line in device.render_body(53)] + assert len(lines) <= 24 # still inside the viewport it was handed + # The echo sits directly above the heading of the list it narrows — the list, not + # the canvas, is what cedes the row for it. + echo = next(i for i, line in enumerate(lines) if line.strip() == "/al") + assert lines[echo + 1].startswith("Matches") + # Clearing the find takes the row back with it. + for _ in "al": + device.handle("backspace") + assert not any(line.strip().startswith("/") for line in + (_plain(l) for l in device.render_body(53))) + finally: + set_platform(REGULAR) + + def test_walk_fkey_lane_says_you_where_the_shared_lane_would_say_top() -> None: """Home drops the trail rather than scrolling, and End is bound to nothing at all.""" from meshterm.ui.tui.fkeys import action_for