Map: the find echo rides the bottom row, and You wipes the slate

On the PicoCalc the query overlays the canvas last row - directly above
the F-key lane - so the ground never shifts when a find begins. You
(Ctrl+L) now clears the query as it comes home; its Shift half, You +,
also zooms in close, and Clear behind Frame drops the query outright.
Shifted arrows fine-pan on the handheld too: the Shift watcher covers a
console that strips the modifier off an arrow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-08-08 19:12:14 -04:00
1 parent 3e7a1893a4
commit e9af03f227
2 files changed
+71 -33

No files matched your search

+57 -25
View File
@@ -6,11 +6,13 @@ the UI never blocks on the network), and redraws via :func:`~meshterm.ui.map_ren
render_map`. Keys:
* the **arrow keys** pan; holding **Shift** pans by a single character cell for fine
positioning,
positioning (on the PicoCalc too — the Shift watcher covers a console that strips the
modifier off an arrow),
* ``PgUp`` / ``PgDn`` zoom in / out,
* ``Home`` recenters and refits to the dense core of the nodes — the *region* the mesh
covers, and the same default view the map opens on,
* ``Ctrl+L`` recenters on **your own node**, keeping the zoom you chose,
* ``Ctrl+L`` recenters on **your own node**, keeping the zoom you chose and clearing any
find (the lane's ``You`` chip; its Shift half also zooms in close),
* **typing finds nodes**: every letter key feeds a live name filter — matching nodes keep
bright labels while the rest dim to context, ``Enter`` frames the matches, ``Backspace``
edits, and ``Esc`` clears the filter (a second ``Esc`` leaves the map). This is why no
@@ -31,6 +33,7 @@ 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 import modifier_watch
from ..services.basemap import BasemapSource
from .map_render import MapMarker, render_map
from .tui.render import query_line
@@ -99,16 +102,28 @@ class MapScreen(Screen):
node to be on the map at all; Frame needs a query with matches to frame, so on an
unfiltered map it dims (and no-ops) rather than standing in for Region.
Two slots carry a Shift half along their own axis (JP, 2026-08-08). Behind You
sits **You +**: the same jump home, but zoomed in close — the ``+`` borrowed from
the zoom rocker's vocabulary, so the pair reads as "you / you, closer". Behind
Frame sits **Clear**: the find axis's other end, dropping the query the way Frame
commits it — lit exactly while there is a query to drop, which Esc also does but
no chip could otherwise teach.
The zoom pair keeps the lane's handedness (see
:data:`~meshterm.ui.tui.fkeys.DEFAULT_LANE`): out on the left, in on the right, so
F4/F5 read as the ``−``/``+`` rocker they are.
"""
from .tui.fkeys import FPair
me = self._self_marker() is not None
return [
FPair("Region", "home"),
FPair("You", "locate", enabled=self._self_marker() is not None),
FPair("Frame", "frame", enabled=bool(self._filter) and bool(self._matches())),
FPair("You", "locate", "You +", "locate_zoom", enabled=me, opp_enabled=me),
FPair(
"Frame", "frame", "Clear", "clear_find",
enabled=bool(self._filter) and bool(self._matches()),
opp_enabled=bool(self._filter),
),
FPair("Zoom -", "pagedown"),
FPair("Zoom +", "pageup"),
]
@@ -207,8 +222,8 @@ 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.
def _query_echo(self) -> bool:
"""Whether this paint echoes the find query over the canvas's bottom row.
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
@@ -220,16 +235,16 @@ class MapScreen(Screen):
def render_body(self, width: int) -> list[str]:
"""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.
Where the platform needs a body-line query echo (see :meth:`_query_echo`), it is
drawn *over* the canvas's last row — directly above the F-key lane — rather than
stacked above the map: an extra head line used to shift the whole ground down a
row the moment a find began (JP, 2026-08-08). Overlaying costs a strip of ground
behind the echo while a query is live, but the viewport itself never resizes, so
nothing jumps and the pan/zoom geometry holds steady.
"""
_, 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, max(1, cell_h - len(head)) * 4
dot_w, dot_h = cell_w * 2, max(1, cell_h) * 4
if self._viewport is None:
self._viewport = self._initial_viewport(dot_w, dot_h)
@@ -242,7 +257,10 @@ 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 head + render_map(self._viewport, tiles, self._markers, find=self._filter)
lines = render_map(self._viewport, tiles, self._markers, find=self._filter)
if lines and self._query_echo():
lines[-1] = query_line(self._filter, width)
return lines
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.
@@ -370,9 +388,11 @@ class MapScreen(Screen):
active filter first, the map itself only once the filter is clear.
Three actions reframe the view, and each has both a key and an F-key chip:
``home`` the whole region, ``locate`` (Ctrl+L) our own node, ``frame`` the find
matches — which is what Enter already does while a query is being typed, kept as
a separate action so the lane can name it on a platform that draws no hint line.
``home`` the whole region, ``locate`` (Ctrl+L) our own node — ``locate_zoom``
the Shift-bank variant that also homes in — and ``frame`` the find matches,
which is what Enter already does while a query is being typed, kept as a
separate action so the lane can name it on a platform that draws no hint line.
``clear_find`` drops the query without leaving (Esc's first peel, as a chip).
"""
vp = self._viewport
if action == "escape":
@@ -384,7 +404,11 @@ class MapScreen(Screen):
if vp is None:
return
if action in _PAN_DIRS:
self._pan(vp, action, fine=False)
# A shifted arrow the console reports as the bare arrow (the PicoCalc's VT
# strips the modifier) still fine-pans: the watcher knows whether Shift is
# physically held, and it stays False wherever it isn't watching — desktop
# terminals report shift_up/... themselves, on the branch below.
self._pan(vp, action, fine=modifier_watch.shift_down())
elif action.startswith("shift_") and action[len("shift_"):] in _PAN_DIRS:
self._pan(vp, action[len("shift_"):], fine=True)
elif action == "pageup":
@@ -395,6 +419,10 @@ class MapScreen(Screen):
self._reset_view(vp)
elif action == "locate":
self._locate(vp)
elif action == "locate_zoom":
self._locate(vp, zoom_in=True)
elif action == "clear_find":
self._filter = ""
elif action == "frame" and self._filter:
self._frame_matches(vp)
elif action == "text" and self.find_enabled:
@@ -418,18 +446,22 @@ class MapScreen(Screen):
"""
return next((m for m in self._markers if m.is_self), None)
def _locate(self, vp: Viewport) -> None:
"""Recentre on our own node, keeping the current zoom (``Ctrl+L`` / the ``You`` chip).
def _locate(self, vp: Viewport, *, zoom_in: bool = False) -> None:
"""Recentre on our own node (``Ctrl+L`` / the ``You`` chip), clearing any find.
Deliberately *only* a recentre: the zoom is the one the user chose, so pressing
this twice does the same thing twice and pairing it with one Zoom + is a single
extra press. Refitting instead would make "where am I" silently also mean "and
forget how close I was looking".
Plain ``You`` keeps the zoom the user chose — pressing it twice does the same
thing twice, and pairing it with one Zoom + is a single extra press. Its Shift
half (``You +``) is the one that also homes in, at the same street-level
closeness a single-match Frame lands on (:data:`_FIND_ZOOM`). Both clear the
find query (JP, 2026-08-08): jumping home while a filter dims the rest — or
matches nothing, us included — reads as starting over, and the map should agree.
"""
me = self._self_marker()
if me is None:
return
self._viewport = Viewport(clamp_lat(me.lat), me.lon, vp.zoom, vp.dot_w, vp.dot_h)
self._filter = ""
zoom = min(_FIND_ZOOM, self._max_tile_zoom) if zoom_in else vp.zoom
self._viewport = Viewport(clamp_lat(me.lat), me.lon, zoom, vp.dot_w, vp.dot_h)
def _reset_view(self, vp: Viewport) -> None:
"""Refit the view to the nodes' dense core (the map's opening frame)."""
+14 -8
View File
@@ -1138,16 +1138,21 @@ def test_map_fkey_lane_names_its_three_destinations_and_the_zoom() -> None:
lane = screen.fkey_lane
# PgUp/PgDn zoom here and Home reframes — the shared paging labels would all be lies,
# and "end" is bound to nothing at all, so no jump rides the Shift bank.
# The zoom pair rises to the right, like every directional pair on the lane.
# and "end" is bound to nothing at all, so no jump rides the zoom rocker's Shift bank.
# The zoom pair rises to the right, like every right-hand directional pair.
assert [pair.label if pair else None for pair in lane] == [
"Region", "You", "Frame", "Zoom -", "Zoom +",
]
assert all(action_for(lane, n) is None for n in range(6, 11))
assert action_for(lane, 1) == "home" and action_for(lane, 5) == "pageup"
assert action_for(lane, 2) == "locate" and action_for(lane, 3) == "frame"
# Region and the zoom always act; the other two only where they'd land somewhere.
# Two slots carry a Shift half along their own axis: You + homes in on us zoomed,
# Clear drops the find query. The other three Shift keys stay unbound.
assert action_for(lane, 7) == "locate_zoom" and action_for(lane, 8) == "clear_find"
assert all(action_for(lane, n) is None for n in (6, 9, 10))
# Region and the zoom always act; the other two only where they'd land somewhere —
# and each Shift half gates with its own axis (no self marker, no query typed).
assert [pair.enabled for pair in lane] == [True, False, False, True, True]
assert lane[1].opp_enabled is False and lane[2].opp_enabled is False
def test_map_locate_recenters_on_our_own_node_at_the_current_zoom() -> None:
@@ -1204,11 +1209,12 @@ def test_map_echoes_the_find_query_in_the_body_only_where_the_footer_is_gone() -
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"
# The echo overlays the canvas's *last* row — directly above the F-key lane —
# so the ground never shifts: same body height, same viewport, top anchor held.
assert _plain(lines[-1]).strip() == "/yul"
assert "/yul" not in _plain(lines[0])
assert len(lines) == 23
# Clearing the find hands the row back.
# Clearing the find hands the row back to the canvas, still without a reflow.
for _ in "yul":
device.handle("backspace")
assert len(device.render_body(53)) == 23