From cbdabef17441b6e2fb80b37898ac06e8565a20c6 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sat, 8 Aug 2026 17:35:20 -0400 Subject: [PATCH] Find query: put it where the platform can actually show it The map and the mesh walk echo the query you are typing in their footer hint. The PicoCalc draws no footer hint -- the F-key lane took that row -- so on the device both screens silently filtered themselves while the reader had no way to see what they had typed. The app already had an answer: the select list and the path composer each draw the query as a warn-styled /query line above the rows it narrows. That is now render.query_line, one definition, and the map and the walk draw it too -- but only where the footer isn't, so the desktop is untouched and no screen ever shows the query twice. The row it costs comes from what the query narrows: the walk's link list, the map's canvas. The map rebuilds its viewport a row shorter through the ordinary resize path, so beginning a find loses a strip of ground along the bottom rather than pushing the last row out of frame. base_body_size was still mirroring only compose_base's bordered branch, so a screen sizing itself against it left one row of a borderless frame permanently blank -- which is the row the echo now wants back. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 6 ++++++ meshterm/ui/map_screen.py | 26 +++++++++++++++++++++--- meshterm/ui/path_composer.py | 4 ++-- meshterm/ui/tui/render.py | 25 ++++++++++++++++++++++++ meshterm/ui/tui/select.py | 4 ++-- meshterm/ui/tui/session.py | 11 ++++++++--- meshterm/ui/walk_screen.py | 18 +++++++++++++++-- tests/test_map.py | 38 ++++++++++++++++++++++++++++++++++++ tests/test_walk.py | 33 +++++++++++++++++++++++++++++++ 9 files changed, 153 insertions(+), 12 deletions(-) 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