The credit was stamped over the right end of the drawing's own last row on both platforms. That is the traditional corner and it is where a frame with no bottom border has to put it, but where there *is* a bottom border there is already a rule under the body doing nothing except closing the box — and a caption set into it costs the picture nothing at all. JP, looking at the desktop map: "that way it's not in the map." So a screen may now hand the frame a bottom_caption, which _panel_box sets into the bottom rule right-justified through Rich's own subtitle machinery — landing as "└──… © OpenStreetMap ─┘", one rule cell before the corner, the mirror of the way a title sits in the top rule. The rule's other occupant keeps its place: the clip arrows still lead, centred when they are alone and joined by the caption, right, when both are wanted, because dropping either would be the frame deciding something neither screen asked of it. The caption joins the base box memo's key as well — the credit collapses on a keystroke that need not change a single body line, and a memo blind to it would go on serving a rule the reader has already grown out of. The map asks attribution for both halves and tests nothing itself: rule_caption gives the caption where the platform's frame has a rule, map_body stamps the drawing where it hasn't, and exactly one of them draws. Which it is, is bound once at platform-switch time rather than asked per paint. Nothing about the PicoCalc changes — same strings, same corner, same collapse, and the minimap still stamps on both platforms, having no frame of its own to set anything into. JP read that form on the device's own panel on 13 September and signed it off as it stands; the docstring now says so, so nobody goes tidying it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1411 lines
71 KiB
Python
1411 lines
71 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
"""The interactive full-screen map: a pannable, zoomable slippy map in the terminal.
|
||
|
||
This drives the braille street map inside the TUI. It owns a :class:`~meshterm.core.geo.
|
||
Viewport` over the mesh's nodes, fetches the vector tiles covering it in the background (so
|
||
the UI never blocks on the network), and redraws via :func:`~meshterm.ui.map_render.
|
||
render_map`. Keys:
|
||
|
||
* the **arrow keys** pan; holding **Shift** pans by a single character cell for fine
|
||
positioning. On the PicoCalc the Shift watcher supplies the modifier the console
|
||
strips: its keymap turns Shift+↑/↓ into PgUp/PgDn (rescued back into fine pans here —
|
||
no physical PgUp exists there, so the code can't mean anything else) and eats
|
||
Shift+←/→ outright — those never reach the app at all until the console keymap maps
|
||
them back to plain arrows,
|
||
* ``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,
|
||
* ``^U`` (for *you*) 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, ``Backspace`` edits, ``^Enter`` frames the
|
||
matches (keeping the query), and ``Enter`` or ``Esc`` drops the query where it is
|
||
without moving the view (a second ``Esc`` leaves the map). This is why no plain letters
|
||
are bound to actions here,
|
||
* ``Esc`` leaves the map.
|
||
|
||
With no network (and no cached tiles) the basemap is simply absent and nodes are plotted on a
|
||
blank grid — the map still works, it just has no streets.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import math
|
||
from collections import OrderedDict
|
||
from collections.abc import Callable
|
||
from time import monotonic
|
||
from typing import TYPE_CHECKING
|
||
|
||
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 TILE_RETRY_SECONDS, BasemapSource
|
||
from . import attribution
|
||
from .map_render import Ghost, MapMarker, render_ground, render_map
|
||
from .tui.render import query_text
|
||
from .tui.screen import Screen
|
||
|
||
if TYPE_CHECKING:
|
||
from ..context import AppContext
|
||
|
||
#: Fraction of the view a single (coarse) pan keypress moves.
|
||
_PAN_STEP = 0.30
|
||
|
||
#: Unit pan direction (east, south) for each directional action / key.
|
||
_PAN_DIRS: dict[str, tuple[int, int]] = {
|
||
"up": (0, -1),
|
||
"down": (0, 1),
|
||
"left": (-1, 0),
|
||
"right": (1, 0),
|
||
}
|
||
|
||
#: How far past the tile source's max zoom the display may go (lower tiles are magnified).
|
||
_OVERZOOM = 2
|
||
|
||
|
||
def _loop_running() -> bool:
|
||
"""Whether there is an event loop to hand background work to.
|
||
|
||
Asked *before* building a coroutine, not after: ``ensure_future`` without a loop
|
||
raises, but by then the coroutine exists and never gets awaited, which Python reports
|
||
as a resource warning on a path that is otherwise perfectly correct (a CLI export or a
|
||
test rendering a map with no loop at all).
|
||
"""
|
||
try:
|
||
asyncio.get_running_loop()
|
||
except RuntimeError:
|
||
return False
|
||
return True
|
||
|
||
|
||
#: How many screens' worth of decoded tiles to keep resident, as a multiple of what the
|
||
#: current view needs. A decoded tile costs ~0.9 MB on the PicoCalc (62 bytes a point,
|
||
#: measured) against a device that has ~100 MB in total, so a map panned far enough would
|
||
#: otherwise fill memory with ground the user has left behind. Keeping the screen plus one
|
||
#: screen of history costs almost nothing to get wrong: an evicted tile comes back from
|
||
#: the decoded cache in ~25-50 ms, having already been parsed once.
|
||
_TILE_CACHE_SCREENS = 2
|
||
|
||
#: Floor on that budget, so a zoomed-out view needing one or two tiles still keeps enough
|
||
#: history for a pan away and back to be instant.
|
||
_MIN_TILE_CACHE = 8
|
||
|
||
#: Whether a moved view is answered with the **rough first pass** before its finished
|
||
#: frame — the ground fills, the watercourses and the through-roads, in a quarter of the
|
||
#: time (see :func:`~meshterm.ui.map_render.render_ground`). Currently **off**: every view
|
||
#: waits for the whole picture, and what it stands on until then is the last frame's
|
||
#: ground reprojected (:class:`~meshterm.ui.map_render.Ghost`), or nothing where the view
|
||
#: has run past that too.
|
||
#:
|
||
#: The switch is here rather than in the renderer because it is a decision about *when to
|
||
#: ask*, not about what can be drawn: both passes still work, both are still tested, and
|
||
#: the abandonment point between them (:meth:`MapScreen._draw_ground`) is still where a
|
||
#: held pan key gets off. Turning it back on is this line.
|
||
_COARSE_PREVIEW = False
|
||
|
||
#: How many pans in one direction it takes to read as a heading rather than a nudge.
|
||
_PREFETCH_MOMENTUM = 2
|
||
|
||
#: How long the view must have held still before the prefetcher starts guessing. A raster
|
||
#: finishing between two keypresses of a pan is not a pause — starting a fetch there puts
|
||
#: a tile decode in the way of the next frame, which measured ~110 ms on the device. Long
|
||
#: enough to tell a pause from a gap, short enough to still be reading time.
|
||
_PREFETCH_SETTLE = 1.0
|
||
|
||
#: How many tiles the prefetcher will ask for around one view before it is satisfied. A
|
||
#: bound rather than a target: the plan usually runs dry first, and this stops a view at a
|
||
#: tile-grid corner from walking the whole neighbourhood.
|
||
_PREFETCH_MAX = 12
|
||
|
||
#: The zoom a frame homes in at when the matches set no extent of their own — a
|
||
#: single node (or several at one spot) has nothing to frame, so ^Enter zooms to this
|
||
#: street-level closeness rather than the fit's neutral default. Capped at the tile
|
||
#: source's max so it never over-zooms onto blank tiles.
|
||
_FIND_ZOOM = 16
|
||
|
||
|
||
class MapScreen(Screen):
|
||
"""A full-screen, keyboard-driven map of the mesh's located nodes over an OSM basemap."""
|
||
|
||
floating = False
|
||
|
||
#: Whether printable keys feed the find-as-you-type node filter. The location
|
||
#: picker turns this off: there, typing has no job and a silent filter would
|
||
#: mysteriously dim the context markers.
|
||
find_enabled = True
|
||
|
||
@property
|
||
def fkey_lane(self):
|
||
r"""The PicoCalc lane: three ways to frame the view, then the zoom rocker.
|
||
|
||
The map is the one screen that repurposes the nav actions wholesale: PgUp/PgDn
|
||
zoom, Home reframes, and ``end`` is bound to nothing at all — so the shared lane's
|
||
Shift-bank jumps have nothing to jump to here and F4/F5 are lone slots. That is
|
||
also the one exception to the rule that a Home/End verb rides the Shift half of
|
||
its pager (JP, 2026-08-08): ``Region`` is not a vertical move through a body, it
|
||
is a *destination*, and it keeps the prime F1 slot next to the other two.
|
||
|
||
All three destinations answer "where should I be looking?", in widening order of
|
||
specificity: the whole **Region** the mesh covers, **You** at the centre of it, or
|
||
just the nodes a find query **Frame**\\ s. Region always acts; You needs our own
|
||
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. Enter and Esc both do
|
||
that on a keyboard; the chip is how the pair teaches it where there is no hint line.
|
||
|
||
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", "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"),
|
||
]
|
||
|
||
def __init__(
|
||
self,
|
||
session, # noqa: ANN001 - TuiSession, imported lazily to avoid a cycle
|
||
markers: list[MapMarker],
|
||
source: BasemapSource,
|
||
max_tile_zoom: int,
|
||
*,
|
||
saved_view: tuple[float, float, int] | None = None,
|
||
on_view_change: Callable[[Viewport], None] | None = None,
|
||
view_fraction: float = DEFAULT_VIEW_FRACTION,
|
||
find: str = "",
|
||
) -> None:
|
||
"""Create the map screen.
|
||
|
||
Args:
|
||
session: The running TUI session (for size + repaint scheduling).
|
||
markers: The located mesh nodes to plot (must be non-empty).
|
||
source: The vector-tile source (already resolved/warmed).
|
||
max_tile_zoom: The source's max zoom, captured off the event loop at open time.
|
||
saved_view: A previously persisted ``(center_lat, center_lon, zoom)`` to reopen
|
||
on, or ``None`` to frame the nodes instead.
|
||
on_view_change: Called with the viewport whenever the centre or zoom changes, so
|
||
the caller can persist it. Deduplicated — only actual changes fire it.
|
||
view_fraction: Fraction of the nodes the default frame (and ``r`` reset) fits —
|
||
the densest that many, so outliers don't dominate. See :meth:`geo.Viewport.fit`.
|
||
find: A find query to open with, exactly as if the user had typed it — the
|
||
matching nodes light and the rest dim (the node-detail page seeds its node's
|
||
name here). Editable and Esc-clearable like any typed find; ``""`` = off.
|
||
"""
|
||
super().__init__()
|
||
self.title = "Map"
|
||
self._session = session
|
||
self._markers = markers
|
||
self._source = source
|
||
self._max_tile_zoom = max_tile_zoom
|
||
self._saved_view = saved_view
|
||
self._on_view_change = on_view_change
|
||
self._view_fraction = view_fraction
|
||
# The view last handed to ``on_view_change``; seeded with the restored view so
|
||
# reopening unchanged doesn't rewrite it.
|
||
self._last_saved = saved_view
|
||
#: The live find-as-you-type node filter ("" = off). Every printable key lands
|
||
#: here — the map binds no letters to actions — and rendering highlights the
|
||
#: matching markers while dimming the rest. A caller may seed it (``find``), which
|
||
#: behaves exactly like a query the user had already typed.
|
||
self._filter = find
|
||
self._viewport: Viewport | None = None
|
||
self._size: tuple[int, int] = (0, 0) # (dot_w, dot_h) the viewport is built for
|
||
# Ask the session to scrub the panel's right edge on the next paint (see
|
||
# :meth:`consume_edge_scrub`). Seeded ``True`` so the first braille frame's edge is
|
||
# cleaned even before the first pan.
|
||
self._needs_scrub = True
|
||
#: Whether the reader has yet touched this map. It gates the basemap credit's two
|
||
#: forms (see :mod:`meshterm.ui.attribution`): the whole line while nothing has
|
||
#: been pressed — the attribution may not be something you have to interact to see
|
||
#: — collapsing to the bare OpenStreetMap credit on the first key the map handles,
|
||
#: which is the "automatically on map interaction such as panning, clicking, or
|
||
#: zooming" clause of OSMF's guideline. A visit's worth of state, not a session's:
|
||
#: a map reopened is a map arrived at again.
|
||
self._untouched = True
|
||
# Decoded tiles, least-recently-shown first — see :meth:`_trim_tiles`. A stored
|
||
# ``None`` is the source's own answer that there is nothing at those coordinates.
|
||
self._tiles: OrderedDict[tuple[int, int, int], list[Layer] | None] = OrderedDict()
|
||
self._pending: set[tuple[int, int, int]] = set()
|
||
# Tiles the source gave no answer about, and when each may be asked for again —
|
||
# a cooldown, not a verdict (see :meth:`_load`).
|
||
self._unanswered: dict[tuple[int, int, int], float] = {}
|
||
# Anticipation (see :meth:`_prefetch_plan`): which way the view has been moving and
|
||
# for how many steps, the tiles already speculated on, and the one fetch in flight.
|
||
self._heading: str | None = None
|
||
self._momentum = 0
|
||
# When the view last stopped changing, and which view that was (monotonic).
|
||
self._settled_at = 0.0
|
||
self._settled_view: Viewport | None = None
|
||
self._speculated: OrderedDict[tuple[int, int, int], None] = OrderedDict()
|
||
self._speculating = False
|
||
# The last finished ground frame and what it was drawn for — see :meth:`render_body`.
|
||
# Rasterizing a downtown view is ~0.5-1 s of pure Python (tens of thousands of
|
||
# vector features), far too slow to sit on a keystroke, so it happens off the paint
|
||
# path and the paint serves whatever is ready.
|
||
self._frame: list[str] | None = None
|
||
self._frame_key: tuple | None = None
|
||
# Whether that frame is only the coarse first pass, and so still owes its detail.
|
||
self._frame_coarse = False
|
||
# That frame's ground, kept so a view that has moved on can stand on it until its
|
||
# own is drawn (see :meth:`_ground`).
|
||
self._ghost: Ghost | None = None
|
||
self._drawing: tuple | None = None # the key currently being rasterized
|
||
# The next raster to draw: its key plus the whole scene it stands for — viewport,
|
||
# markers, find query — snapshotted at request time (see :meth:`_schedule_ground`).
|
||
self._wanted: tuple[tuple, Viewport, list[MapMarker], str] | None = None
|
||
|
||
# --- rendering -----------------------------------------------------------
|
||
|
||
@property
|
||
def footer_hint(self) -> str: # type: ignore[override]
|
||
"""Key hints — or the live find query.
|
||
|
||
While a find filter is active the hints give way to the query itself with its
|
||
editing keys, so the typed text is always visible somewhere fixed.
|
||
|
||
The line spends its whole 72-cell budget, so the two view-jump keys share one atom
|
||
(``Home/^U region/you``) and ``⇧ fine`` — a refinement of a key the line already
|
||
names, and the only atom here that documents a *modifier* rather than a binding —
|
||
is the one that gives way to make room for them. The basemap's state used to hang
|
||
off the end of this line as a suffix; it is a status atom, not a key, so it moved
|
||
to the title where the standards chain those (see :meth:`_title`), which is what
|
||
finally brought the line inside the budget.
|
||
"""
|
||
if self._filter:
|
||
return f"find: {self._filter}▏ · ^Enter frame · ⌫ erase · Enter/Esc clear"
|
||
return "↑↓←→ pan · PgUp/PgDn zoom · Home/^U region/you · type to find · Esc back"
|
||
|
||
def consume_edge_scrub(self) -> int:
|
||
"""Right-edge columns the session should force-repaint on the next paint (0 = none).
|
||
|
||
A braille glyph the terminal font lacks is substituted by a *double-width* fallback,
|
||
which shoves the row and smears the panel's right padding and border. The map body
|
||
itself redraws wholesale as it pans, so those cells self-heal — but the static edge
|
||
does not change frame-to-frame, so prompt_toolkit's differential paint never rewrites
|
||
it and the smear lingers there. After a move we ask the session to force just those
|
||
two columns (right padding + border) to repaint, scrubbing the smear without the
|
||
whole-frame flicker a full repaint would cause.
|
||
"""
|
||
if not self._needs_scrub:
|
||
return 0
|
||
self._needs_scrub = False
|
||
return 2 # the panel's right padding cell and its right border cell
|
||
|
||
def set_markers(self, markers: list[MapMarker]) -> None:
|
||
"""Replace the plotted nodes on the open map — nodes that arrived after it opened.
|
||
|
||
The drawn frame is for the old overlay, so it stops being served; until the new one
|
||
lands, the nodes are drawn over that frame's ground rather than over black (see
|
||
:meth:`_ground`). The view stays where it is: the reader may already have moved it.
|
||
"""
|
||
self._markers = markers
|
||
self._frame_key = None
|
||
self._session.invalidate()
|
||
|
||
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
|
||
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.
|
||
|
||
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.
|
||
|
||
The order matters at the end: the prefetcher only guesses while the screen owes
|
||
the reader nothing, and what this paint owes is not known until :meth:`_ground`
|
||
has asked for its raster. Read a step earlier — from inside :meth:`_ensure_tiles`,
|
||
where it used to live — it sees the *previous* paint's answer, and a settled map
|
||
given a find keystroke starts a speculative fetch in the very paint that queues
|
||
the frame the reader is waiting for.
|
||
|
||
The basemap credit is stamped last, over the right end of that same bottom row
|
||
(see :mod:`meshterm.ui.attribution`), because its two forms turn on what the
|
||
reader has pressed rather than on what the raster shows — a frame is half a second
|
||
of Python and is served across many paints, so a credit baked into one would
|
||
either lag the keystroke that collapses it or force a redraw to shrink a caption.
|
||
Where the query echo wants this row too, the two share it: query left, credit
|
||
right, and the query is the half that gives way.
|
||
"""
|
||
vp = self._ensure_viewport(width)
|
||
self._ensure_tiles(vp)
|
||
self._persist()
|
||
lines = self._ground(vp)
|
||
self._ensure_prefetch(vp) # last: it reads what this paint just asked for
|
||
self.title = self._title(vp)
|
||
echo = query_text(self._filter) if self._query_echo() else None
|
||
return attribution.map_body(lines, width, full=self._untouched, left=echo)
|
||
|
||
@property
|
||
def bottom_caption(self) -> str: # type: ignore[override]
|
||
"""The basemap credit, for a frame whose bottom rule can carry it.
|
||
|
||
The other half of :func:`~meshterm.ui.attribution.map_body`, and the half that
|
||
costs the drawing nothing: where the panel has a bottom border, the credit is set
|
||
into it right-justified rather than stamped over the map's own last row (JP,
|
||
2026-09-13 — *"that way it's not in the map"*). Empty where the frame has no rule
|
||
to set it into, which is exactly where ``map_body`` stamps instead, so between
|
||
them the credit is drawn once on every platform and twice on none.
|
||
|
||
Read per paint, like the title, so the collapse to
|
||
:data:`~meshterm.ui.attribution.CREDIT_SHORT` reaches the rule on the very
|
||
keystroke that causes it.
|
||
"""
|
||
return attribution.rule_caption(full=self._untouched)
|
||
|
||
def _ensure_viewport(self, width: int) -> Viewport:
|
||
"""The viewport for a body ``width`` cells wide — built on first paint, else resized.
|
||
|
||
Split out of :meth:`render_body` because a subclass may need the view *before* it
|
||
renders anything: the picker's crosshair rides the centre, so it has to know where
|
||
the centre is to build its marker (see :meth:`LocationPickScreen.render_body`).
|
||
"""
|
||
_, cell_h = self._session.base_body_size()
|
||
dot_w, dot_h = width * 2, max(1, cell_h) * 4
|
||
if self._viewport is None:
|
||
self._viewport = self._initial_viewport(dot_w, dot_h)
|
||
self._size = (dot_w, dot_h)
|
||
elif self._size != (dot_w, dot_h):
|
||
self._viewport = self._viewport.resized(dot_w, dot_h)
|
||
self._size = (dot_w, dot_h)
|
||
return self._viewport
|
||
|
||
def _ground_key(self, vp: Viewport) -> tuple:
|
||
"""Everything the rasterized ground is a function of.
|
||
|
||
The tile *identities* go in the key rather than their contents: a tile's decoded
|
||
layers never change once loaded, so a tile arriving is the only way the picture can
|
||
gain detail, and that shows up here as a new key.
|
||
"""
|
||
loaded = tuple(t for t in vp.tiles(self._max_tile_zoom) if self._tiles.get(t))
|
||
return (vp, loaded, self._filter, len(self._markers))
|
||
|
||
def _ground(self, vp: Viewport) -> list[str]:
|
||
"""The map picture for ``vp`` — from the last raster if it still applies, else soon.
|
||
|
||
Rasterizing a view is 0.5-1 s of pure Python on the PicoCalc (a downtown frame
|
||
projects tens of thousands of vector features), so it cannot happen between a
|
||
keypress and the paint that answers it. Instead the paint always returns
|
||
immediately, with the best picture available *right now*, and a background task
|
||
draws the real one and asks for a repaint when it lands:
|
||
|
||
* **Nothing has changed** — the raster is exactly this view: serve it (and if it
|
||
is only the coarse first pass, ask for the finishing one behind it).
|
||
* **Only the tiles changed** (a fetch landed, the view did not move) — the previous
|
||
raster is still correctly aligned, just missing some streets. Keep showing it
|
||
rather than blanking a good picture to redraw the same ground.
|
||
* **The view moved** — the old raster is in the wrong place *as a picture*, but the
|
||
ground it drew is still the only ground anyone has: reproject it onto the new
|
||
viewport (:class:`~meshterm.ui.map_render.Ghost`) and draw the nodes over it at
|
||
their real positions. That costs a few milliseconds, so panning still tracks the
|
||
keys exactly, and the streets slide with the view — dimmed, and short of the
|
||
edge you are panning onto — instead of the map blanking to black between every
|
||
keypress and flashing back when the frame lands (JP, 2026-08-09).
|
||
|
||
The last of those is where the map goes black *and stays black*: four coarse pan
|
||
steps are 120% of the screen, so nothing drawn is under the view any more and
|
||
there is no ground to reproject. The answer to that is not this method but the one
|
||
it schedules — :meth:`_draw_ground` can land a rough picture in a quarter of the
|
||
time rather than nothing at all for a whole raster, which is
|
||
:data:`_COARSE_PREVIEW`, currently off.
|
||
"""
|
||
key = self._ground_key(vp)
|
||
if self._frame_key == key and self._frame is not None:
|
||
if self._frame_coarse:
|
||
self._schedule_ground(key, vp) # the first pass is on screen; finish it
|
||
return list(self._frame)
|
||
|
||
self._schedule_ground(key, vp)
|
||
if self._aligned(key) and self._frame is not None:
|
||
return list(self._frame) # same view, only tiles differ — still aligned
|
||
# The view moved (or nothing has ever been drawn): markers over the last ground.
|
||
return render_map(vp, {}, self._markers, find=self._filter, ghost=self._ghost)
|
||
|
||
def _aligned(self, key: tuple) -> bool:
|
||
"""Whether the drawn frame is this view's picture, merely short of some detail.
|
||
|
||
Everything but the tiles (``key[1]``) has to match: a frame drawn for a different
|
||
marker set is not "the same picture missing streets", it is a picture missing a
|
||
node — the picker's crosshair, say. A frame that *is* aligned stays on screen
|
||
while the newer one draws.
|
||
|
||
This is a question about the whole *picture*, which is why it is not the question
|
||
:meth:`_ground_drawn` asks.
|
||
"""
|
||
if self._frame is None or self._frame_key is None:
|
||
return False
|
||
return self._frame_key[0] == key[0] and self._frame_key[2:] == key[2:]
|
||
|
||
def _ground_drawn(self, key: tuple) -> bool:
|
||
"""Whether this view's ground is already drawn in full, so a rough pass would undo it.
|
||
|
||
The two passes exist for a view that has run past every scrap of drawn ground; a
|
||
view whose ground is *already there* wants none of the first one, because a coarse
|
||
picture of ground the reader can already see takes detail away (buildings, back
|
||
streets, every street name) for the length of a raster.
|
||
|
||
Only the viewport is asked about, and that is the difference from
|
||
:meth:`_aligned`. The find query and the marker count belong to the *overlay* —
|
||
change one and the frame on screen is the wrong picture and stops being served,
|
||
but the ground under it is the same ground, still drawn, still correct, and
|
||
reprojected onto the very next paint at zero offset as the stand-in
|
||
(:class:`~meshterm.ui.map_render.Ghost`, published with the frame it came from).
|
||
Asking the fuller question here meant every letter of a find query flattened the
|
||
streets to the rough pass and drew them back, once per keystroke.
|
||
"""
|
||
if self._frame_key is None or self._frame_coarse:
|
||
return False
|
||
return self._frame_key[0] == key[0]
|
||
|
||
def _schedule_ground(self, key: tuple, vp: Viewport) -> None:
|
||
"""Note that ``key`` wants drawing, and start on it if nothing else is in flight.
|
||
|
||
Exactly **one** raster runs at a time, and it is always the newest one asked for.
|
||
A held arrow key hands us a new viewport on every repaint, and a render is most of
|
||
a second: starting one per keypress would pile up a queue of thread-bound work,
|
||
each frame of it already stale on arrival, and the contention would slow the very
|
||
keystrokes this is meant to keep quick. So a request that arrives mid-draw only
|
||
replaces the pending one, and the draw that finishes picks it up.
|
||
|
||
The markers and the find query are **snapshotted here**, alongside the viewport, so
|
||
the raster draws the very scene ``key`` stands for. The draw itself happens later,
|
||
on a thread, long after the paint that asked for it returned — and a marker list
|
||
can be per-frame: the picker's crosshair is appended for the duration of one
|
||
``render_body`` and taken straight back out (see
|
||
:meth:`LocationPickScreen.render_body`). Reading it at draw time would find it
|
||
gone, and the finished basemap would land over the crosshair and erase it.
|
||
"""
|
||
if key == self._drawing or (key == self._frame_key and not self._frame_coarse):
|
||
return
|
||
self._wanted = (key, vp, list(self._markers), self._filter)
|
||
if self._drawing is None:
|
||
self._start_ground()
|
||
|
||
def _start_ground(self) -> None:
|
||
"""Begin the pending raster, or draw it inline where there is no event loop."""
|
||
if self._wanted is None:
|
||
return
|
||
key, vp, markers, find = self._wanted
|
||
self._wanted = None
|
||
tiles = {t: self._tiles.get(t) for t in vp.tiles(self._max_tile_zoom)}
|
||
if not _loop_running():
|
||
# A static render (the CLI's map export, a test): there is nothing to be
|
||
# responsive *to*, so draw it here and now rather than never.
|
||
self._drawing = None
|
||
self._frame, self._ghost = render_ground(vp, tiles, markers, find=find)
|
||
self._frame_key, self._frame_coarse = key, False
|
||
return
|
||
self._drawing = key
|
||
preview = _COARSE_PREVIEW and not self._ground_drawn(key)
|
||
asyncio.ensure_future(self._draw_ground(key, vp, tiles, markers, find, preview))
|
||
|
||
async def _draw_ground(
|
||
self,
|
||
key: tuple,
|
||
vp: Viewport,
|
||
tiles: dict,
|
||
markers: list[MapMarker],
|
||
find: str,
|
||
preview: bool,
|
||
) -> None:
|
||
"""Rasterize one view off the event loop, coarse then finished, repainting at each.
|
||
|
||
The work is pure Python, so a thread does not truly run it in parallel — but the
|
||
interpreter still switches between threads every few milliseconds, which is the
|
||
whole point: keystrokes keep being serviced throughout instead of waiting for the
|
||
frame (measured worst-case delay ~50 ms, against the ~1 s of a blocking draw).
|
||
|
||
**Two passes, because a whole frame is too big a thing to wait for or to throw
|
||
away** — where ``preview`` asks for both, which :data:`_COARSE_PREVIEW` currently
|
||
does not. A coarse pass (ground, water, through-roads; see
|
||
:func:`~meshterm.ui.map_render.render_ground`) is a quarter of the work — 249 ms
|
||
against 962 at zoom 13 on the PicoCalc — and it lands first, so a view that has
|
||
run past every scrap of drawn ground shows something true within a blink instead
|
||
of sitting black. Then the finished pass replaces it in place.
|
||
|
||
The gap between the passes is also where a moving view gets off. A coarse pan step
|
||
is 30% of the screen, so four keypresses leave *nothing* of the last frame under
|
||
the view and the stand-in has no ground to reproject — and each of those presses
|
||
used to have to wait out a full raster being drawn for a view already three steps
|
||
stale. Checking :attr:`_wanted` between the passes cuts the unit of abandonable
|
||
work to the coarse one, so a pan being held is answered with real ground roughly
|
||
four times as often, and the finished frame is drawn for where the user actually
|
||
stopped.
|
||
|
||
Everything the frame is a function of arrives as an argument (see
|
||
:meth:`_schedule_ground`) — the screen's own state may have moved on by the time
|
||
the thread runs, and the frame is filed under the key of the scene it was asked
|
||
for, so it must *be* that scene.
|
||
|
||
Args:
|
||
key: What the finished frame will be filed under.
|
||
vp: The view to draw.
|
||
tiles: The decoded tiles it draws from.
|
||
markers: The nodes to overlay, snapshotted at request time.
|
||
find: The live find filter, likewise.
|
||
preview: Whether to draw the coarse pass first. Off wholesale while
|
||
:data:`_COARSE_PREVIEW` is; and skipped anyway where this view's ground is
|
||
already drawn in full (see :meth:`_ground_drawn`), since there a coarse
|
||
picture would *remove* detail the reader can already see.
|
||
"""
|
||
if preview and await self._pass(key, vp, tiles, markers, find, coarse=True):
|
||
if self._wanted is not None:
|
||
self._drawing = None # the view has moved on; draw where it is now
|
||
self._start_ground()
|
||
return
|
||
await self._pass(key, vp, tiles, markers, find, coarse=False)
|
||
self._drawing = None
|
||
if self._wanted is not None:
|
||
self._start_ground()
|
||
|
||
async def _pass(
|
||
self,
|
||
key: tuple,
|
||
vp: Viewport,
|
||
tiles: dict,
|
||
markers: list[MapMarker],
|
||
find: str,
|
||
*,
|
||
coarse: bool,
|
||
) -> bool:
|
||
"""Draw one pass off the loop and publish it; report whether it landed."""
|
||
try:
|
||
drawn = await asyncio.to_thread(
|
||
render_ground, vp, tiles, markers, find=find, coarse=coarse
|
||
)
|
||
except Exception: # noqa: BLE001 - a frame we couldn't draw is one we draw again
|
||
return False
|
||
lines, self._ghost = drawn
|
||
self._frame, self._frame_key, self._frame_coarse = lines, key, coarse
|
||
self._needs_scrub = True
|
||
self._session.invalidate()
|
||
return True
|
||
|
||
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.
|
||
|
||
With no saved view the default frames the half of the nodes nearest the median
|
||
centre, so distant outliers don't zoom the whole mesh out to a useless scale.
|
||
"""
|
||
if self._saved_view is not None:
|
||
lat, lon, zoom = self._saved_view
|
||
z = max(2, min(int(zoom), self._max_tile_zoom + _OVERZOOM))
|
||
return Viewport(clamp_lat(lat), lon, z, dot_w, dot_h)
|
||
return Viewport.fit(
|
||
[(m.lat, m.lon) for m in self._markers],
|
||
dot_w,
|
||
dot_h,
|
||
max_zoom=self._max_tile_zoom,
|
||
fraction=self._view_fraction,
|
||
)
|
||
|
||
def _persist(self) -> None:
|
||
"""Hand the current centre/zoom to ``on_view_change`` if it changed since last time."""
|
||
vp = self._viewport
|
||
if vp is None or self._on_view_change is None:
|
||
return
|
||
view = (vp.center_lat, vp.center_lon, vp.zoom)
|
||
if view == self._last_saved:
|
||
return
|
||
self._last_saved = view
|
||
self._on_view_change(vp)
|
||
|
||
def _matches(self) -> list[MapMarker]:
|
||
"""The markers the live find filter currently matches (all of them when off)."""
|
||
needle = self._filter.strip().casefold()
|
||
if not needle:
|
||
return self._markers
|
||
return [m for m in self._markers if needle in m.label.casefold()]
|
||
|
||
def _title(self, vp: Viewport) -> str:
|
||
"""A compact status title: zoom, node count (find matches), and ground scale.
|
||
|
||
The basemap's own state — tiles still in flight, or no source at all — is the last
|
||
atom when there is one to report, standing in for the scale rather than joining it.
|
||
Both are answers to "how much ground am I looking at", the status is the more urgent
|
||
of the two while it lasts, and swapping (rather than appending) keeps the title from
|
||
outgrowing a 53-column title bar, which clips rather than wraps.
|
||
"""
|
||
# Ground metres per braille dot at the view centre, for a rough sense of scale.
|
||
m_per_dot = (
|
||
2
|
||
* math.pi
|
||
* EARTH_RADIUS_KM
|
||
* 1000
|
||
* math.cos(math.radians(vp.center_lat))
|
||
/ (256 * (2**vp.zoom))
|
||
)
|
||
scale = (
|
||
f"{m_per_dot * vp.dot_w:.0f} m across"
|
||
if m_per_dot * vp.dot_w < 1000
|
||
else f"{m_per_dot * vp.dot_w / 1000:.1f} km across"
|
||
)
|
||
if self._pending:
|
||
scale = f"{len(self._pending)} tiles…"
|
||
elif self._drawing is not None or self._wanted is not None:
|
||
# The ground for this view is still being rasterized off the paint path (see
|
||
# :meth:`_ground`), so what is on screen is the nodes alone, or the last view's
|
||
# streets. Say so, in the same slot the tile fetch reports from.
|
||
scale = "drawing…"
|
||
elif not self._source.available:
|
||
scale = "offline"
|
||
if self._filter:
|
||
nodes = f"{len(self._matches())} of {len(self._markers)} match"
|
||
else:
|
||
nodes = f"{len(self._markers)} nodes"
|
||
return f"Map · z{vp.zoom} · {nodes} · {scale}"
|
||
|
||
# --- tiles ---------------------------------------------------------------
|
||
|
||
def _ensure_tiles(self, vp: Viewport) -> None:
|
||
"""Schedule background fetches for any visible tile we don't have and aren't owed.
|
||
|
||
Asked on every paint, and deliberately **not** gated on
|
||
:attr:`~meshterm.services.basemap.BasemapSource.available`: that only reports
|
||
whether a template is known *yet*, and skipping the fetch while it isn't is how a
|
||
map that opened a moment too early stays empty for the rest of the session. The
|
||
fetch resolves the source itself, in its own thread, so asking is what un-sticks it.
|
||
|
||
A tile the source never answered about waits out
|
||
:data:`~meshterm.services.basemap.TILE_RETRY_SECONDS` in
|
||
:attr:`_unanswered` and is then asked for again — see :meth:`_load` for why that
|
||
isn't the same as a tile it answered "nothing here" about.
|
||
"""
|
||
wanted = vp.tiles(self._max_tile_zoom)
|
||
for t in wanted:
|
||
if t in self._tiles:
|
||
self._tiles.move_to_end(t) # on screen now, so last in line to be dropped
|
||
self._trim_tiles(len(wanted))
|
||
if not _loop_running(): # nothing to fetch onto — a static render draws what it has
|
||
return
|
||
self._expire_cooldowns()
|
||
for t in wanted:
|
||
if t in self._tiles or t in self._pending or t in self._unanswered:
|
||
continue
|
||
self._pending.add(t)
|
||
asyncio.ensure_future(self._load(t))
|
||
|
||
def _expire_cooldowns(self) -> None:
|
||
"""Forget the silences that have served their time, so a long pan can't hoard them."""
|
||
if not self._unanswered:
|
||
return
|
||
now = monotonic()
|
||
self._unanswered = {t: at for t, at in self._unanswered.items() if at > now}
|
||
|
||
# --- anticipation ---------------------------------------------------------
|
||
|
||
def _ensure_prefetch(self, vp: Viewport) -> None:
|
||
"""Fetch one tile the next move is likely to need — but only in the quiet.
|
||
|
||
A tile is 1.4 s off the PicoCalc's Wi-Fi and a few hundred milliseconds to decode
|
||
the first time, so ground that is fetched only once it is looked at arrives after
|
||
it was wanted. Fetching it a move early costs nothing the user can feel *provided
|
||
it is never the thing in the way*, and that is the whole of the pacing rule here:
|
||
the prefetcher runs only when the view it is guessing from is finished *and has
|
||
been for a moment* — every visible tile in, no raster running, nothing queued, and
|
||
:data:`_PREFETCH_SETTLE` of that since. Those are the seconds the user spends
|
||
reading the screen, which are also the seconds before they move; the wait is what
|
||
keeps a raster landing between two keypresses of a pan from being mistaken for a
|
||
pause, and it is measured from when the screen went quiet rather than from when
|
||
the view stopped moving, because a frame that took a second to draw has not been
|
||
looked at for a second.
|
||
|
||
One fetch at a time, and the finished one asks for the next itself
|
||
(:meth:`_prefetch`) rather than waiting for a repaint: chaining through the paint
|
||
would cost a 40-80 ms interim frame per tile on the device, to show a picture that
|
||
has not changed.
|
||
|
||
All of which rests on being asked at the *end* of a paint, once :meth:`_ground`
|
||
has said what this frame owes — see :meth:`render_body`.
|
||
"""
|
||
# Still owing the reader something: a tile in flight, a raster running or queued.
|
||
busy = bool(self._pending) or self._drawing is not None or self._wanted is not None
|
||
if busy or self._settled_view is not vp:
|
||
self._settled_view, self._settled_at = vp, monotonic()
|
||
if busy or self._speculating or not _loop_running():
|
||
return
|
||
if monotonic() - self._settled_at < _PREFETCH_SETTLE:
|
||
return # a raster landing between two keypresses of a pan is a gap, not a pause
|
||
tile = self._next_speculation(vp)
|
||
if tile is None:
|
||
return
|
||
self._speculating = True
|
||
self._remember_speculation(tile)
|
||
asyncio.ensure_future(self._prefetch(tile, vp))
|
||
|
||
def _next_speculation(self, vp: Viewport) -> tuple[int, int, int] | None:
|
||
"""The first tile in :meth:`_prefetch_plan` we have neither got nor guessed at.
|
||
|
||
A tile serving out its silence (:attr:`_unanswered`) is passed over as well, and
|
||
that is the same rule as the pacing above rather than an extra one: the cooldown
|
||
exists because the source has just gone quiet about that square and hammering it
|
||
helps nobody (see :meth:`_load`), and a guess is the last request that should be
|
||
the exception. It comes back into the plan when the cooldown expires — by which
|
||
time the reader may well have panned onto it, and then it is *their* fetch, made
|
||
on their behalf, which is the one the cooldown was always sized for.
|
||
"""
|
||
for tile in self._prefetch_plan(vp):
|
||
if tile in self._tiles or tile in self._pending or tile in self._speculated:
|
||
continue
|
||
if tile in self._unanswered:
|
||
continue # the source just went quiet about it; a guess must not push
|
||
return tile
|
||
return None
|
||
|
||
def _prefetch_plan(self, vp: Viewport) -> list[tuple[int, int, int]]:
|
||
"""The tiles the next few moves would want, in the order they'd be wanted.
|
||
|
||
What the user does next is not a mystery to be modelled. Whatever they press, the
|
||
ground it reveals comes from the **ring of tiles around the ones on screen** —
|
||
that is what a pan uncovers, in whichever direction — or from one of the two
|
||
neighbouring zooms, which are the only moves whose tiles are a different set
|
||
entirely. So the plan is that ring and those two views, ordered by what the reader
|
||
just did:
|
||
|
||
* **Ahead first.** A pan is rarely alone; the view is being carried somewhere. The
|
||
side of the ring the heading points at is fetched before any other, and once the
|
||
run reads as a heading rather than a nudge
|
||
(:data:`_PREFETCH_MOMENTUM`) the corners flanking it come too, because a
|
||
diagonal is two keys and readers steer.
|
||
* **Then out, then in.** Zooming out is how you find where you are, and it is the
|
||
move that shares nothing with the screen: a step out is four times the ground at
|
||
a tile zoom never visited. A step in is cheap to ask for and often free to
|
||
answer — at or above the source's max zoom it is the same tiles, magnified.
|
||
* **Then the rest of the ring**, for a reader who hasn't moved yet or is about to
|
||
change their mind. With no heading at all this is the whole of it, nearest
|
||
neighbours before corners, which is the right hedge when there is nothing to read.
|
||
|
||
Working in tiles rather than in pan steps matters at the zooms where a single step
|
||
uncovers no new tile at all: the plan asks for the ground a move *reaches*, not
|
||
the ground one keypress lands on.
|
||
|
||
Deliberately not here: the nodes a find could jump to. Those are a keystroke away
|
||
all over the map, so speculating on them is speculating on everything — and the
|
||
jump reframes the view anyway, arriving here as a new view with its own plan.
|
||
"""
|
||
visible = vp.tiles(self._max_tile_zoom)
|
||
if not visible:
|
||
return []
|
||
tz = visible[0][0]
|
||
span = 2**tz
|
||
xs = [x for _, x, _ in visible]
|
||
ys = [y for _, _, y in visible]
|
||
x0, x1, y0, y1 = min(xs), max(xs), min(ys), max(ys)
|
||
seen = set(visible)
|
||
|
||
ahead: list[tuple[int, int, int]] = []
|
||
flank: list[tuple[int, int, int]] = []
|
||
hx, hy = _PAN_DIRS.get(self._heading or "", (0, 0))
|
||
corners = self._momentum >= _PREFETCH_MOMENTUM
|
||
for ty in range(y0 - 1, y1 + 2):
|
||
if not 0 <= ty < span:
|
||
continue
|
||
for tx in range(x0 - 1, x1 + 2):
|
||
tile = (tz, tx % span, ty) # wrap x around the antimeridian, as tiles() does
|
||
if tile in seen:
|
||
continue
|
||
seen.add(tile)
|
||
# Which way this tile lies from the block on screen: -1, 0 or +1 per axis.
|
||
off_x = -1 if tx < x0 else (1 if tx > x1 else 0)
|
||
off_y = -1 if ty < y0 else (1 if ty > y1 else 0)
|
||
towards = off_x * hx + off_y * hy
|
||
if towards > 0 and (corners or not (off_x and off_y)):
|
||
ahead.append(tile)
|
||
else:
|
||
# No heading: straight neighbours before corners. With one: everything
|
||
# that isn't ahead is equally a change of mind, so distance decides.
|
||
flank.append(tile)
|
||
flank.sort(key=lambda t: abs(t[1] - (x0 + x1) // 2) + abs(t[2] - (y0 + y1) // 2))
|
||
|
||
zooms: list[tuple[int, int, int]] = []
|
||
for view in (vp.zoomed(-1), vp.zoomed(1, max_zoom=self._max_tile_zoom + _OVERZOOM)):
|
||
for tile in view.tiles(self._max_tile_zoom):
|
||
if tile not in seen:
|
||
seen.add(tile)
|
||
zooms.append(tile)
|
||
# With a heading to follow: ahead, then the zooms, then the change of mind. With
|
||
# none: every way a pan could go *first*, because the arrows are how this screen
|
||
# is driven and a zoom guessed at ahead of them is a tile the pan then waits for.
|
||
plan = ahead + zooms + flank if ahead else flank + zooms
|
||
return plan[:_PREFETCH_MAX]
|
||
|
||
def _remember_speculation(self, tile: tuple[int, int, int]) -> None:
|
||
"""Note that we've already guessed at ``tile``, keeping the record bounded.
|
||
|
||
Guessed-at, not *held*: what a prefetch leaves behind is a decoded tile in the
|
||
source's own cache (on disk, and in its small resident memo), which is the whole
|
||
point — the screen keeps only what it is drawing. This is just the note that stops
|
||
the plan from asking twice, and it is allowed to forget, since forgetting costs at
|
||
worst a cache hit.
|
||
"""
|
||
self._speculated[tile] = None
|
||
self._speculated.move_to_end(tile)
|
||
while len(self._speculated) > _PREFETCH_MAX * 4:
|
||
self._speculated.popitem(last=False)
|
||
|
||
async def _prefetch(self, tile: tuple[int, int, int], vp: Viewport) -> None:
|
||
"""Warm one tile into the source's cache, then take the next guess.
|
||
|
||
The result is thrown away on purpose. A prefetched tile is not this screen's to
|
||
hold — :attr:`_tiles` is the view's working set and has a budget sized to it — and
|
||
it does not need to be: the fetch has written the tile and its decoded form to
|
||
disk and left it in the source's resident memo, so the paint that finally wants it
|
||
gets it in tens of milliseconds instead of a second and a half.
|
||
|
||
No repaint either way: nothing on screen changed, and the whole point of doing
|
||
this early was to not spend the user's time.
|
||
"""
|
||
try:
|
||
await asyncio.to_thread(self._source.load_tile, *tile)
|
||
except Exception: # noqa: BLE001 - a guess that didn't pay off is not an error
|
||
pass
|
||
self._speculating = False
|
||
if self._viewport is vp: # still the same view, so the same plan: keep going
|
||
self._ensure_prefetch(vp)
|
||
|
||
def _trim_tiles(self, in_view: int) -> None:
|
||
"""Release the least recently shown tiles once the view's budget is exceeded.
|
||
|
||
Only tiles holding geometry are counted or dropped. An entry whose value is
|
||
``None`` is the source's word that the tile is absent — a settled answer costing a
|
||
dict slot rather than a megabyte, so it stays; dropping it would only buy a
|
||
pointless re-request on the next repaint. A tile we got no answer about isn't here
|
||
at all: it waits out its cooldown in :attr:`_unanswered` and is asked for again.
|
||
|
||
Args:
|
||
in_view: How many tiles the current viewport needs, which sets the budget.
|
||
"""
|
||
budget = max(_MIN_TILE_CACHE, in_view * _TILE_CACHE_SCREENS)
|
||
loaded = sum(1 for layers in self._tiles.values() if layers)
|
||
if loaded <= budget:
|
||
return
|
||
# Oldest first; everything on screen was just moved to the end, so it is safe.
|
||
for key in list(self._tiles):
|
||
if loaded <= budget:
|
||
break
|
||
if self._tiles[key]:
|
||
del self._tiles[key]
|
||
loaded -= 1
|
||
|
||
async def _load(self, t: tuple[int, int, int]) -> None:
|
||
"""Fetch+decode one tile off the event loop, then repaint.
|
||
|
||
What comes back is filed under the same distinction the tile source keeps (see
|
||
:class:`~meshterm.services.basemap._Response`), because this is where forgetting it
|
||
costs a picture. Geometry, or the source's own word that there is nothing at those
|
||
coordinates, is an answer: it goes in :attr:`_tiles` and is never asked about
|
||
again. Silence — offline, a timeout, a body the Wi-Fi cut short — is *not* an
|
||
answer, and filing it as one turns one bad moment into a black square that stays
|
||
black until the app is restarted. It goes in :attr:`_unanswered` instead, which is
|
||
a cooldown rather than a verdict.
|
||
|
||
That distinction is only visible on a link that actually drops. On the PicoCalc it
|
||
is the difference between a map and a map with holes in it (JP, 2026-08-18: tiles
|
||
black at the two highest zooms, where one z14 tile is the whole screen).
|
||
"""
|
||
try:
|
||
layers = await asyncio.to_thread(self._source.load_tile, *t)
|
||
except Exception: # noqa: BLE001 - a failed tile is just an absent one
|
||
layers = None
|
||
self._pending.discard(t)
|
||
if layers is not None or self._source.answered_empty(*t):
|
||
self._tiles[t] = layers
|
||
else:
|
||
self._unanswered[t] = monotonic() + TILE_RETRY_SECONDS
|
||
self._session.invalidate()
|
||
|
||
# --- input ---------------------------------------------------------------
|
||
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
"""Pan, zoom, reframe, edit the find filter, or exit.
|
||
|
||
Every printable key feeds the find filter — nothing pans or zooms by letter, so
|
||
typing a node name can never fling the view around. Esc peels one layer: an
|
||
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`` (^U, for *you*) our own node — ``locate_zoom``
|
||
the Shift-bank variant that also homes in — and ``frame`` (^Enter) the find
|
||
matches. ``clear_find`` drops the query without moving the view, which is also
|
||
what plain **Enter** does.
|
||
|
||
Enter and ^Enter are the find's two ways out, and they split along whether the
|
||
*view* moves (JP, 2026-08-09). Typing a query dims everything that doesn't match,
|
||
and the common finish is "yes, that one — now let me look around it": the query
|
||
has done its job and only the dimming is in the way, so Enter drops the query and
|
||
leaves the view exactly where the reader put it. ^Enter is the other finish —
|
||
*take* me to them — and it keeps the query, because a frame is a place to arrive
|
||
and framing tighter from there is one more press, not a re-type.
|
||
"""
|
||
vp = self._viewport
|
||
if action == "escape":
|
||
if self._filter:
|
||
self._filter = ""
|
||
else:
|
||
self.resolve(None)
|
||
return
|
||
if vp is None:
|
||
return
|
||
if action in _PAN_DIRS:
|
||
# A shifted arrow the console reports as the bare arrow (a keymap that
|
||
# 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":
|
||
if modifier_watch.shift_down():
|
||
# The PicoCalc console's keymap translates Shift+↑ into PgUp (measured
|
||
# on-device, JP 2026-08-09: shifted vertical arrows were zooming). No
|
||
# physical PgUp exists on that keyboard — the pager rides the F-lane —
|
||
# so a raw PgUp with Shift held can only *be* a shifted arrow: fine-pan.
|
||
self._pan(vp, "up", fine=True)
|
||
else:
|
||
self._reorient()
|
||
self._viewport = vp.zoomed(1, max_zoom=self._max_tile_zoom + _OVERZOOM)
|
||
elif action == "pagedown":
|
||
if modifier_watch.shift_down():
|
||
self._pan(vp, "down", fine=True) # Shift+↓ arrives as PgDn — see above
|
||
else:
|
||
self._reorient()
|
||
self._viewport = vp.zoomed(-1)
|
||
elif action in ("home", "ctrl_home"):
|
||
self._reorient()
|
||
self._reset_view(vp)
|
||
elif action == "locate":
|
||
self._reorient()
|
||
self._locate(vp)
|
||
elif action == "locate_zoom":
|
||
self._reorient()
|
||
self._locate(vp, zoom_in=True)
|
||
elif action in ("clear_find", "enter"):
|
||
self._filter = ""
|
||
elif action in ("frame", "ctrl_enter") and self._filter:
|
||
self._reorient()
|
||
self._frame_matches(vp)
|
||
elif action == "text" and self.find_enabled:
|
||
if not data.isspace() or self._filter: # never begin the filter with a space
|
||
self._filter += data
|
||
elif action == "space" and self._filter:
|
||
self._filter += " " # node names carry spaces; only meaningful mid-query
|
||
elif action == "backspace":
|
||
self._filter = self._filter[:-1]
|
||
# Any handled key may have redrawn the body, so clean the right edge next paint —
|
||
# and it is also the moment the map stops being one the reader has merely arrived
|
||
# at, which collapses the basemap credit to its remnant (see :attr:`_untouched`).
|
||
self._needs_scrub = True
|
||
self._untouched = False
|
||
self._persist()
|
||
|
||
def _self_marker(self) -> MapMarker | None:
|
||
"""Our own node among the markers, or ``None`` when the map can't place us.
|
||
|
||
A device with no location fix of its own is simply absent from the marker list, so
|
||
``You`` has nowhere to go and its chip dims (see :attr:`fkey_lane`).
|
||
"""
|
||
return next((m for m in self._markers if m.is_self), None)
|
||
|
||
def _locate(self, vp: Viewport, *, zoom_in: bool = False) -> None:
|
||
"""Recentre on our own node (``^U`` / the ``You`` chip), clearing any find.
|
||
|
||
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._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)."""
|
||
self._viewport = Viewport.fit(
|
||
[(m.lat, m.lon) for m in self._markers],
|
||
vp.dot_w,
|
||
vp.dot_h,
|
||
max_zoom=self._max_tile_zoom,
|
||
fraction=self._view_fraction,
|
||
)
|
||
|
||
def _frame_matches(self, vp: Viewport) -> None:
|
||
"""Refit the view around the find filter's matches (^Enter on an active find).
|
||
|
||
All matches are framed (``fraction=1.0`` — the user asked for exactly these
|
||
nodes, so no dense-core trimming). A single match — or several at one spot — has
|
||
no extent to frame, so instead of the fit's neutral default the view homes in
|
||
close on it (:data:`_FIND_ZOOM`, capped at the tile source's max so it never
|
||
over-zooms onto blank tiles). No matches at all leaves the view alone.
|
||
"""
|
||
matches = self._matches()
|
||
if not matches:
|
||
return
|
||
self._viewport = Viewport.fit(
|
||
[(m.lat, m.lon) for m in matches],
|
||
vp.dot_w,
|
||
vp.dot_h,
|
||
max_zoom=self._max_tile_zoom,
|
||
default_zoom=min(_FIND_ZOOM, self._max_tile_zoom),
|
||
fraction=1.0,
|
||
)
|
||
|
||
def _reorient(self) -> None:
|
||
"""Forget which way the view was being carried — this move is not a continuation.
|
||
|
||
A zoom or a jump home is the reader looking *around* rather than travelling, and
|
||
the ground it lands on says nothing about which way they will go from there. The
|
||
prefetcher reads the cleared heading as "no reading available" and hedges the four
|
||
directions evenly instead of buying ground ahead of a pan that has ended.
|
||
"""
|
||
self._heading, self._momentum = None, 0
|
||
|
||
def _pan(self, vp: Viewport, direction: str, *, fine: bool) -> None:
|
||
"""Pan by one coarse step, or — when ``fine`` — a single character cell.
|
||
|
||
A character cell is 2 braille dots wide and 4 tall, so the fine step is that many
|
||
dots expressed as a fraction of the current view.
|
||
"""
|
||
dx, dy = _PAN_DIRS[direction]
|
||
# Which way the view is being carried, and for how long — what the prefetcher
|
||
# reads to tell a heading from a nudge (see :meth:`_prefetch_plan`). A fine step
|
||
# is a correction, not a direction, so it holds the heading without extending it.
|
||
if not fine:
|
||
self._momentum = self._momentum + 1 if direction == self._heading else 1
|
||
self._heading = direction
|
||
if fine:
|
||
self._viewport = vp.panned(dx * 2 / vp.dot_w, dy * 4 / vp.dot_h)
|
||
else:
|
||
self._viewport = vp.panned(dx * _PAN_STEP, dy * _PAN_STEP)
|
||
|
||
|
||
class LocationPickScreen(MapScreen):
|
||
"""The map, repurposed as a coordinate picker: pan the crosshair, Enter to choose.
|
||
|
||
Used by the config editor to set the node's advertised location by *pointing at the
|
||
map* instead of typing degrees. It is a :class:`MapScreen` with three changes: a
|
||
crosshair marker rides the view centre (labelled with the live coordinates, so the
|
||
user always sees exactly what they're about to pick), Enter resolves with the
|
||
centre's ``(lat, lon)`` instead of framing find matches (find is off here — see
|
||
:attr:`MapScreen.find_enabled`), and ``Home`` recentres on the *initial* location
|
||
rather than refitting the node cloud. The surrounding mesh nodes are still drawn, so
|
||
placing yourself relative to a known repeater is easy. Esc cancels (resolves CANCEL,
|
||
surfaced as ``None`` by the caller).
|
||
"""
|
||
|
||
find_enabled = False
|
||
|
||
@property
|
||
def fkey_lane(self): # type: ignore[override]
|
||
"""The map's lane minus the two verbs a picker has no use for.
|
||
|
||
``Frame`` goes because find is off here — nothing can ever be typed to frame, so
|
||
the slot is *empty*, not dim. ``You`` goes because the whole screen is about
|
||
choosing where "you" will be: the crosshair at the centre already is that answer,
|
||
and a chip that jumped to wherever the node currently claims to be would compete
|
||
with the pick rather than help it. ``Home`` keeps F1, but reframed: here it returns
|
||
to the view the picker **opened** on, so a pan that went wrong is one key to undo.
|
||
"""
|
||
from .tui.fkeys import FPair
|
||
|
||
return [
|
||
FPair("Start", "home"),
|
||
None,
|
||
None,
|
||
FPair("Zoom -", "pagedown"),
|
||
FPair("Zoom +", "pageup"),
|
||
]
|
||
|
||
def __init__(
|
||
self,
|
||
session, # noqa: ANN001 - TuiSession, imported lazily to avoid a cycle
|
||
markers: list[MapMarker],
|
||
source: BasemapSource,
|
||
max_tile_zoom: int,
|
||
*,
|
||
initial: tuple[float, float] | None = None,
|
||
zoom: int = 13,
|
||
) -> None:
|
||
"""Create the picker.
|
||
|
||
Args:
|
||
session: The running TUI session (for size + repaint scheduling).
|
||
markers: Located mesh nodes to draw for context (may be empty).
|
||
source: The vector-tile source (already resolved/warmed).
|
||
max_tile_zoom: The source's max zoom, captured off the event loop at open time.
|
||
initial: The location to open centred on (the node's current position), or
|
||
``None`` to frame the mesh instead (falling back to a world view when no
|
||
nodes are located either).
|
||
zoom: The zoom to open at when ``initial`` is given.
|
||
"""
|
||
saved = (initial[0], initial[1], zoom) if initial is not None else None
|
||
super().__init__(session, markers, source, max_tile_zoom, saved_view=saved)
|
||
self._initial = initial
|
||
self._pick_zoom = zoom
|
||
|
||
@property
|
||
def footer_hint(self) -> str: # type: ignore[override]
|
||
"""Key hints for picking, plus the live tile-loading indicator."""
|
||
base = "↑↓←→ pan · ⇧ fine · PgUp/PgDn zoom · Enter set location · Esc cancel"
|
||
if self._pending:
|
||
return f"{base} · [muted]loading {len(self._pending)} tiles…[/muted]"
|
||
if not self._source.available:
|
||
return f"{base} · [warn]offline — no basemap[/warn]"
|
||
return base
|
||
|
||
def _initial_viewport(self, dot_w: int, dot_h: int) -> Viewport:
|
||
"""Open on the initial location, else frame the nodes, else a world view."""
|
||
if self._saved_view is None and not self._markers:
|
||
return Viewport(20.0, 0.0, 2, dot_w, dot_h) # nothing to frame — the world
|
||
return super()._initial_viewport(dot_w, dot_h)
|
||
|
||
def render_body(self, width: int) -> list[str]:
|
||
"""Render the map with the crosshair marker pinned to the view centre.
|
||
|
||
The crosshair is a transient marker appended for just this frame (never stored in
|
||
:attr:`_markers`), drawn in the "self" style so it reads as *your* position-to-be
|
||
and labelled with the live coordinates it would commit. The viewport is settled
|
||
first (:meth:`~MapScreen._ensure_viewport`) so the crosshair rides the centre from
|
||
the very first frame, and rides the *resized* centre when the window changes.
|
||
|
||
Because the marker only exists for the duration of this call, every raster it
|
||
should appear in has to be requested from inside it — which is why the background
|
||
draw snapshots the scene rather than reading it back later (see
|
||
:meth:`~MapScreen._schedule_ground`); otherwise the finished basemap would land
|
||
over the crosshair and the picker would lose sight of what it is picking.
|
||
"""
|
||
real = self._markers
|
||
vp = self._ensure_viewport(width)
|
||
cross = MapMarker(
|
||
label=f"⌖ {vp.center_lat:.5f}, {vp.center_lon:.5f}",
|
||
lat=vp.center_lat,
|
||
lon=vp.center_lon,
|
||
is_self=True,
|
||
)
|
||
self._markers = real + [cross]
|
||
try:
|
||
return super().render_body(width)
|
||
finally:
|
||
self._markers = real
|
||
|
||
def _title(self, vp: Viewport) -> str:
|
||
"""A live status title: the coordinates under the crosshair and the scale."""
|
||
base = super()._title(vp)
|
||
scale = base.rsplit("·", 1)[-1].strip()
|
||
return f"Set location · {vp.center_lat:.5f}, {vp.center_lon:.5f} · z{vp.zoom} · {scale}"
|
||
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
"""Commit the centre on Enter; ``Home`` returns to the initial spot; else map keys."""
|
||
if action == "enter":
|
||
vp = self._viewport
|
||
if vp is not None:
|
||
self.resolve((vp.center_lat, vp.center_lon))
|
||
return
|
||
if action in ("home", "ctrl_home") and self._viewport is not None:
|
||
# Reset returns to the *starting* view — the initial location when one was
|
||
# given, else the node frame — rather than refitting a cloud that now includes
|
||
# nowhere in particular. With neither, fall back to the world view.
|
||
vp = self._viewport
|
||
if self._initial is not None:
|
||
lat, lon = self._initial
|
||
self._viewport = Viewport(clamp_lat(lat), lon, self._pick_zoom, vp.dot_w, vp.dot_h)
|
||
elif not self._markers:
|
||
self._viewport = Viewport(20.0, 0.0, 2, vp.dot_w, vp.dot_h)
|
||
else:
|
||
super().handle(action, data)
|
||
return
|
||
self._needs_scrub = True
|
||
return
|
||
super().handle(action, data)
|
||
|
||
|
||
def coords_or_none(lat: object, lon: object) -> tuple[float, float] | None:
|
||
"""A stored coordinate pair as the picker's opening spot, or ``None`` to frame the mesh.
|
||
|
||
Takes whatever a caller holds — floats from the companion, strings from a repeater's
|
||
CLI, ``None`` where nothing was read — and reads MeshCore's ``0, 0`` "no fix" as no
|
||
location at all, so a node that never had a position opens on the mesh rather than on
|
||
a point in the Gulf of Guinea.
|
||
"""
|
||
try:
|
||
lat_f, lon_f = float(lat), float(lon) # type: ignore[arg-type]
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if abs(lat_f) < 1e-6 and abs(lon_f) < 1e-6:
|
||
return None
|
||
return lat_f, lon_f
|
||
|
||
|
||
async def pick_location(
|
||
ctx: AppContext, *, initial: tuple[float, float] | None = None
|
||
) -> tuple[float, float] | None:
|
||
"""Open the full-screen map as a coordinate picker; return ``(lat, lon)`` or ``None``.
|
||
|
||
Opens on the mesh's located nodes *already in hand* — the session's cached contacts and
|
||
position, and our own history — and never waits on the radio for them: they are context
|
||
for the pick, not part of it, and a companion refusing the contacts read (some do, for a
|
||
stretch) held the picker closed through every retry of it, twenty-odd seconds. Whatever
|
||
the cache lacked is fetched behind the open map and joins it when the radio answers; the
|
||
read is never cancelled, so a shared fetch another screen is waiting on survives the
|
||
picker closing. Then runs a :class:`LocationPickScreen` until the user commits a spot
|
||
with Enter or backs out with Esc.
|
||
|
||
Args:
|
||
ctx: Shared application context (must be in the interactive menu).
|
||
initial: The location to open centred on (e.g. the node's current coordinates),
|
||
or ``None`` to frame the mesh.
|
||
|
||
Raises:
|
||
RuntimeError: If called outside the interactive menu (no full-screen session).
|
||
"""
|
||
import asyncio
|
||
|
||
from ..services.markers import gather_markers
|
||
from .surface import TuiUi
|
||
from .tui.screen import CANCEL
|
||
|
||
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
|
||
raise RuntimeError("the interactive map is only available in the menu")
|
||
session = ctx.ui.session
|
||
devstate = ctx.devstate
|
||
in_hand = devstate.peek_contacts() is not None and devstate.peek_self_info() is not None
|
||
try:
|
||
markers = await gather_markers(ctx, wait=False)
|
||
except Exception: # noqa: BLE001 - context markers are a nicety, never a requirement
|
||
markers = []
|
||
source = basemap_source(ctx)
|
||
max_zoom = await asyncio.to_thread(lambda: source.max_zoom)
|
||
screen = LocationPickScreen(session, markers, source, max_zoom, initial=initial)
|
||
showing = True
|
||
|
||
async def fill_in() -> None:
|
||
"""Add what the cache lacked once the radio answers — or never, at no cost."""
|
||
try:
|
||
fuller = await gather_markers(ctx)
|
||
except Exception: # noqa: BLE001 - the picker already works without them
|
||
return
|
||
if showing and len(fuller) != len(screen._markers):
|
||
screen.set_markers(fuller)
|
||
|
||
if not in_hand:
|
||
asyncio.ensure_future(fill_in())
|
||
try:
|
||
result = await session.run_screen(screen)
|
||
finally:
|
||
showing = False
|
||
# Same clean-slate repaint as open_map: the braille may have smeared the terminal.
|
||
session.request_full_repaint()
|
||
return None if result is CANCEL or result is None else result
|
||
|
||
|
||
def basemap_source(ctx: AppContext) -> BasemapSource:
|
||
"""The session's shared vector-tile source (see :attr:`AppContext.basemap_source`).
|
||
|
||
Memoized on the context, so the one-off TileJSON resolve is paid once for the whole
|
||
session instead of on every map open or Node-detail location preview.
|
||
"""
|
||
return ctx.basemap_source
|
||
|
||
|
||
async def open_map(
|
||
ctx: AppContext,
|
||
markers: list[MapMarker],
|
||
*,
|
||
focus: tuple[float, float] | None = None,
|
||
find: str | None = None,
|
||
fraction: float = DEFAULT_VIEW_FRACTION,
|
||
) -> None:
|
||
"""Open the interactive full-screen map over ``markers`` and run until dismissed.
|
||
|
||
Warms the tile source off the event loop (so the first paint doesn't block on the
|
||
network), then pushes the :class:`MapScreen` and awaits its dismissal.
|
||
|
||
Args:
|
||
ctx: Shared application context (must be in the interactive menu).
|
||
markers: The located mesh nodes to plot (non-empty).
|
||
focus: A ``(lat, lon)`` to open centred on — the node you opened the map from,
|
||
say — instead of the persisted "where you left the map" view. A focused open
|
||
is a transient peek: it deliberately wires no ``on_view_change``, so panning
|
||
around it never overwrites that saved view and the Map tool still reopens
|
||
where the user last left it.
|
||
find: A find query to open with — the focused node's name, so it lights among the
|
||
rest exactly as if the user had typed it. ``None`` opens with the find off.
|
||
fraction: Fraction of the nodes the default view frames (see :class:`MapScreen`).
|
||
|
||
Raises:
|
||
RuntimeError: If called outside the interactive menu (no full-screen session).
|
||
"""
|
||
from .surface import TuiUi
|
||
|
||
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
|
||
raise RuntimeError("the interactive map is only available in the menu")
|
||
session = ctx.ui.session
|
||
source = basemap_source(ctx)
|
||
# Resolve the tile template/zoom in a worker thread so the UI thread never blocks.
|
||
max_zoom = await asyncio.to_thread(lambda: source.max_zoom)
|
||
if focus is not None:
|
||
# Open on the focused node — matching the detail preview's centre and zoom
|
||
# (``min(13, max_zoom)``) so the full map is visibly the same place, larger. No
|
||
# ``on_view_change``: a focused peek must leave the persisted global view alone.
|
||
lat, lon = focus
|
||
screen = MapScreen(
|
||
session,
|
||
markers,
|
||
source,
|
||
max_zoom,
|
||
saved_view=(clamp_lat(lat), lon, min(13, max_zoom)),
|
||
view_fraction=fraction,
|
||
find=find or "",
|
||
)
|
||
else:
|
||
screen = MapScreen(
|
||
session,
|
||
markers,
|
||
source,
|
||
max_zoom,
|
||
saved_view=ctx.repo.get_map_view(),
|
||
on_view_change=lambda vp: ctx.repo.set_map_view(vp.center_lat, vp.center_lon, vp.zoom),
|
||
view_fraction=fraction,
|
||
)
|
||
try:
|
||
await session.run_screen(screen)
|
||
finally:
|
||
# The map's braille may have smeared the terminal via double-width fallback glyphs
|
||
# that prompt_toolkit's diff can't see; force one full repaint so the menu drawn
|
||
# underneath starts from a clean slate rather than inheriting that garbage.
|
||
session.request_full_repaint()
|