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) <noreply@anthropic.com>
This commit is contained in:
1 parent
8a80d47815
commit
cbdabef174
9 files changed
+153
-12
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user