Commit Graph
33 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 c79ade3fab Map: the rough first pass is off, behind a switch rather than a deletion
_COARSE_PREVIEW turns off the two-pass draw: every view now waits for the
whole picture, standing on the last frame's ground reprojected until it
lands, or on nothing where the view has run past that too.

None of the machinery goes. Both passes still draw, render_ground still
takes coarse=, and the abandonment point between them is still where a
held pan key gets off — what the switch decides is only whether a moved
view is offered the first pass, which is why it is read where the raster
is asked for and not inside the renderer. Turning it back on is one line.

The abandonment test needed to say what it means to survive the change:
it asserted no finished pass appeared in a list it read after the loop
closed, which quietly depended on the *next* view's draw not getting a
scheduler step in time. It reads the list where the abandoned key's draw
returns now, and asks only about that key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 01:09:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 b4db55961f Map: three optimisations that were undoing each other's work
The rough first pass and the prefetcher each state a rule about when they
must stand down, and each was reading that rule off the wrong thing.

The prefetcher's rule is that speculation is never the thing in the way:
no guessing while a tile is in flight or a raster is running or queued.
It was asked from inside _ensure_tiles, which runs before _ground — so
what it read was the *previous* paint's answer. On a settled map a find
keystroke moves nothing, so the view is still the one it is entitled to
guess from, and it started a tile decode in the very paint that queued
the frame the reader was waiting for: the ~110 ms in front of the next
frame the pacing was written to avoid. It is asked at the end of the
paint now, once _ground has said what this frame owes.

The rough pass's rule is that it is skipped where the ground is already
drawn, because a coarse picture of streets the reader can see takes
detail away. It decided that on _aligned, which also compares the find
query and the marker count — the *overlay*. Change one and the ground
underneath is the same ground, still drawn, still correct; but the
answer came back "not aligned", so every letter of a find query flattened
the streets to the rough pass and drew them back, once per keystroke.
That question is now its own — _ground_drawn, which asks about the view
alone — and _aligned goes back to being only about the picture.

And a guess must not push past a silence: a tile the source has just gone
quiet about is on a cooldown so an offline map isn't retrying it on a
loop, and the prefetcher was walking straight past that to re-ask it off
its own initiative. It leaves cooling tiles alone; when the cooldown
expires the tile is in the plan again, and if the reader has panned onto
it by then the fetch is theirs, made on their behalf, which is the one
the cooldown was always sized for.

Two constants went with them, both describing mechanisms that aren't
there: _PREFETCH_LEAD, which measured the lead in pan steps the plan
stopped working in, and _COARSE_ROADS, whose class list left out the two
classes (rail, and the unrecognised default) the priority test it was
supposed to mirror actually drops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 00:39:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 98e8e5f260 Map: fetch the ground the next move will want, while nobody is waiting
A tile is 1.4 s off the PicoCalc's Wi-Fi on a good day and a few hundred
milliseconds to decode the first time, so ground fetched only once it is
looked at arrives after it was wanted. It does not have to: whatever the
reader presses next, the ground it reveals comes from the ring of tiles
around the ones on screen, or from one of the two neighbouring zooms —
the only moves whose tiles are a different set entirely. That is a plan,
not a prediction, and what the reader just did orders it.

A run of pans in one direction is a heading, and the side of the ring it
points at is fetched first; once the run is long enough to be a heading
rather than a nudge, the corners flanking it come too, because a diagonal
is two keys and readers steer. Then the step out and the step in. Then the
rest of the ring, which with no heading at all is the whole of the plan,
nearest neighbours before corners. A zoom or a jump home clears the
heading: that is looking around, not travelling on.

Working in tiles rather than in pan steps matters at the zooms where one
keypress uncovers no new tile at all — the plan asks for the ground a move
reaches, not the ground one press lands on.

The pacing is the load-bearing part, because speculation must never be the
thing in the way. One fetch at a time; never while a visible tile is in
flight or a raster is running or queued; and not until a second of quiet
has passed since the last of that cleared — measured from when the screen
went quiet, not from when the view stopped moving, since a frame that took
a second to draw has not been looked at for a second. Without that wait a
raster landing between two keypresses of a pan reads as a pause, and the
tile decode it starts lands in front of the next frame: measured at ~110 ms
on the device, which is exactly what this must not cost.

What a prefetch leaves behind is a tile in the source's cache, on disk and
in its small resident memo. The screen keeps only what it draws.

Measured on the device, panning on after a pause, onto ground never
fetched (zoom 12, three runs):

  without: 0/1 tiles ready, view complete after 2297-2324 ms
  with:    1/1 tiles ready, view complete after   81-112 ms

and the pan-after-idle path is unchanged: 621 ms to ground without, 535 with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:56:08 -04:00
Jean-Pierre MartineauandClaude Opus 5 8b5c27909b Map: draw a rough frame first, so a moved view is never black for long
A coarse pan step is 30% of the view, so four keypresses leave nothing of
the last frame under the screen — there is no ground to reproject, and the
map went black. It then stayed black for a whole raster, which is most of
a second on the PicoCalc, and often for two: the raster in flight was
being drawn for a view the user had already left, and the one they were
actually looking at could not start until it finished.

So the raster is two passes now. The first draws the ground fills, the
watercourses and the through-roads (tertiary and up, derived from the road
styles so the two sets cannot drift) — no buildings, no back streets, no
text at all. It is a quarter of the work: 249 ms against 962 at zoom 13 on
the device, 120 against 529 at zoom 16. The second draws the finished
frame over it.

The gap between them is where a moving view gets off. Checking _wanted
there cuts the abandonable unit of work from a whole raster to the rough
one, so a pan being held is answered with real ground about four times as
often and the finished frame is drawn for where the user actually stopped.

Measured on the device, four quick pan steps from a settled map to the
first ground back on screen:

  zoom 13:  1984 ms -> 617 ms
  zoom 15:   970 ms -> 348 ms

The rough pass is skipped where a frame for this view is already on screen
and merely waiting on a tile: there a coarser picture would take away
detail the reader can already see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:19:35 -04:00
Jean-Pierre MartineauandClaude Opus 5 f5d0fb3677 Map: silence about a tile is not the source saying it's empty
A tile fetch that gets no answer was being remembered as one that did.
BasemapSource is careful about the difference — a timeout is never written
to disk, precisely so the next pan can ask again — and both callers then
threw it away, filing every None as "fetched, empty/absent" and never
asking again for the life of the screen. The same latch sat one level up:
_resolve() marked itself done whether or not it had resolved anything, so
one failed TileJSON round-trip made `available` False, and _ensure_tiles
skipped every fetch, for the whole session.

That is a desktop no-op and a PicoCalc bug. The device brings its Wi-Fi up
some forty seconds into the boot, well after an app started alongside it
reaches the menu and warms the tile source — so the map answers "offline"
before the answer can be true and draws the entire session from whatever
the cache already held. JP, 2026-08-18: black tiles at the two highest
zooms, where one z14 tile is the whole screen and a hole is the picture.
VE2RAD_FH_50m's tile (14/4842/5861) was simply absent from the device
cache; nothing there had been written in ten days of using the map, and it
fetched in 1.4s the moment it was asked for.

So: `available` reports what is known and never itself goes to the network
(it is read on the paint path), a failed resolve stands for 30s rather than
for ever, `answered_empty` lets a caller tell the two Nones apart, and a
tile nobody answered about waits out a 20s cooldown instead of a restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:28:34 -04:00
Jean-Pierre MartineauandClaude Opus 5 3c714e4c84 Map picker: draw the frame the crosshair was in, not the one after it
The picker's crosshair is a per-frame marker: render_body appends it to
_markers, calls up, and takes it straight back out in a finally. But the
real ground raster runs off the paint path, and _draw_ground read
self._markers when the *thread* ran — after that restore. So every
finished basemap frame was drawn without the crosshair, and landing it
erased the pick: pan and the crosshair rides the ghost, stop and the
streets arrive over it.

Snapshot the whole scene — viewport, markers, find query — when the
raster is requested, and hand it to the draw. A frame filed under a key
has to be the scene that key stands for.

Two things fall out of it. The "same view, only tiles differ" shortcut
now compares everything but the tiles, since a frame drawn for a
different marker set is a picture missing a node, not one missing
streets. And _ensure_viewport splits out of render_body, so the picker
settles its view without rendering a whole crosshair-less frame first —
one less full raster on open, and the crosshair rides a resize too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:58:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 07a5c82945 Find, trail, days: three keys that were asking the wrong question
Map. Enter framed the matches, which is only ever half of what finishing a
find means. Typing a query dims everything it 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 where the reader put it, and ^Enter is the other finish --
take me to them -- keeping the query, because a frame is a place to arrive
and framing tighter from there is one more press.

^Enter is not a keycode any terminal has to offer: Enter *is* ^M, so a
console without modifyOtherKeys has nothing left to modify. What most do
send is LF, which prompt_toolkit spells ControlJ and nothing here was using,
so it is bound; the right-Ctrl rescue covers the rest; and where a console
spells neither, the F3 Frame chip is the affordance -- which is the platform
where that was already the whole story.

Mesh walk. The breadcrumb elided its head and snapped right, so a long walk
had a beginning that could not be read at all. Left and right now scroll it
a hop at a time, and a scrolled line grows its own trailing elision: the
mark means the same thing on either side, more walk that way. It stops where
the head comes into view rather than running the line off the edge, and any
change to the trail rewinds it -- the focus is what the next move is read
from. The hint trades "type to find" for the arrows exactly while the trail
overflows: typing announces itself the moment a letter lands, the scroll had
no way to.

And the find now narrows the canvas fan, not just the list. The east of the
picture is the ways onward, which is what the query is asking about; the
focus and the node we came from are the walk itself, so they hold through
whatever is typed and the graph keeps its bearings while the choices thin.

Chat. The transcript's sections are days and its section jump was already
bound, so it takes the free left pair the same way a grouped list's does --
Day up outside Day down, the lane's handedness, ctrl_pageup/ctrl_pagedown --
lit only when there is a second day to step to. Read off the two end
messages rather than by re-dating the whole transcript every paint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:19:24 -04:00
Jean-Pierre MartineauandClaude Opus 5 1eda585c50 F-lane: You was standing in for a list end it isn't
The mesh walk's You had been living behind Page up, in the pager's Shift
bank, because that bank is where a screen's Home lands by default. But You
is not an end of the link list -- it drops the whole trail and refocuses our
own node -- so it had to pretend to be Top, and Bottom had to stay blank to
keep the pretence, and the walk was the one scrolling screen whose Home
didn't scroll.

So You takes a slot of the screen's own (F3, which is what F1-F3 are for),
and the pager's Shift bank goes back to meaning what it means everywhere
else: F10 Top, F9 Bottom, the two ends of the list. Home and End on a
keyboard follow the same correction and now move the highlight.

Its chord moves with it: locate is ^U, for you, on the map as well as here.
The letter is the mnemonic of the action rather than one screen's word for
it -- ^L only ever read as "locate" from inside the map's code, and the
action is the same one on both screens, so it should not have been two
mnemonics waiting to happen.

You dims once the focus is already us and no find is narrowing the list --
there is nowhere to come back from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:37:38 -04:00
Jean-Pierre MartineauandClaude Opus 5 6e0c9ec50b Map: the pan was blanking the ground it already had
Rasterizing a view is most of a second, so the paint that answers a pan
drew the nodes on an empty canvas and waited — the map going black on
every keypress and flashing back when the frame landed. Each finished
frame now keeps its braille layer, and the next moved view stands on it:
the two viewports differ by an affine scale-and-shift in Web Mercator,
which collapses to two cell-index tables the canvas pastes by. So the
streets slide with the view — dimmed, and short of the edge you are
panning onto — until their own raster replaces them.

Cell granularity, not dot-exact: the stand-in lands within half a cell
of true and is corrected a moment later, for +0.5 ms on the paint path
instead of eight times the work. Only the ground travels; labels and
markers are redrawn where they really are. The fade is off on the
16-slot console, where dimming a colour is a recolouring, and the whole
ghost is dropped once it says nothing — panned clear off, or more than
two zoom steps away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 04:32:51 -04:00
Jean-Pierre MartineauandClaude Fable 5 e53e35523e Map: draw the ground off the keystroke, and stop reprojecting the world
A pan cost 0.9-2.4 s. It was never the tiles - those already load in a
thread - it was render_map, which rasterizes the vector ground on the
paint path: 900-2342 ms for a view holding ~16k features.

Two changes, in that order.

The projection was doing the same trigonometry once per vertex. Every
feature_to_dot call recomputed Viewport.origin_world - a sin/log pair -
and a 2** for values constant across the whole frame, 107k times a frame.
The mapping is affine in tile-local coordinates, so tile_transform folds
it into three numbers and _draw_tile spells the arithmetic out inline: two
multiplies and two adds per vertex, no call at all. That alone is 2.4x
(z12 2342 -> 991 ms).

The rest is genuinely per-feature Python and will not shrink to keystroke
size, so the raster moved off the paint path. A paint now returns the best
picture available immediately and a background task draws the real one:
unchanged view serves the finished frame; a view that only gained a tile
keeps the old raster, which is still correctly aligned; a view that *moved*
draws the nodes alone on the new viewport, which is a few ms, so panning
tracks the keys and the streets catch up. The title says "drawing..." while
it does.

Exactly one raster runs at a time and it is always the newest requested -
a held arrow hands us a new viewport every repaint, and starting a second
of thread-bound work per keypress would queue frames that are already
stale on arrival and slow the keystrokes this exists to protect.

On the device: pan 1205 -> 89 ms, 1156 -> 130 ms, 2449 -> 91 ms; zoom
1161 -> 142 ms; a settled view 37 ms.

Also flips MESHTERM_FASTRENDER on by default (=0 still opts out), and asks
whether a loop is running *before* building a coroutine, so a static render
no longer leaves an un-awaited one behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 01:58:08 -04:00
Jean-Pierre MartineauandClaude Fable 5 9f6e1ac891 Map: the console keymap turns Shift+arrows into other keys - rescue what arrives
Shift+Up/Down reach the app as PgUp/PgDn there; with Shift physically
held (the watcher knows) they fine-pan instead of zooming - no real
PgUp exists on that keyboard, so the code cannot mean anything else.
Shift+Left/Right the keymap eats outright; nothing arrives to rescue
until the console keymap maps them back to plain arrows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 00:10:53 -04:00
Jean-Pierre MartineauandClaude Fable 5 e9af03f227 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>
2026-08-08 19:12:14 -04:00
Jean-Pierre MartineauandClaude Opus 5 cbdabef174 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>
2026-08-08 17:35:20 -04:00
Jean-Pierre MartineauandClaude Opus 5 8a80d47815 F-key lane: a jump rides the Shift half of the page it follows
Home and End are on the PicoCalc's keyboard. Paging is not. So the
jump-to-either-end pair was never the unreachable thing that earned it
F1/F2 -- it only ever wanted a chip for consistency with the pager it
belongs to. Now it rides that pager: Shift+F5 is Top because F5 is
Page up, Shift+F4 is Bottom because F4 is Page down. One axis, one pair
of keys, the second-grade verb sitting behind the first.

That frees F1-F3 on every screen, and the lane is the only place the
device can advertise anything -- there is no hint line to read a chord
off. So the freed slots went to the affordances that were invisible
there:

  select lists  Sect down/up, the section jumps. Ctrl+PgUp on a
                keyboard with no PgUp is the one binding in the app
                that was physically unreachable. Plus Delete, lit on
                exactly the rows the key acts on.
  map           Region (Home, renamed -- it frames the ground the mesh
                covers, it does not "reset" anything), You (new: Ctrl+L,
                recentre on our own node at the zoom you chose), and
                Frame for the find matches.
  node page     the tab it would switch you to.
  time machine  the window span it would take you to -- w was
                undiscoverable on the device.

The map is the one exception to the new rule: Region answers "where
should I be looking", not "how far down the body am I", so it keeps F1
beside the other two destinations rather than hiding behind a pager it
has nothing to do with.

Screens that never scroll (every dialog, the busy splash, the reorder
list) now say so with an empty lane instead of offering a pager that
does nothing. Screens whose Home/End mean something else say what:
Latest/Oldest in a chat, Newest/Oldest in the packet viewer, You on the
walk (where End is bound to nothing, so its slot stays blank).

The map's hint line finally fits 72: its basemap status was never a key
hint, so it moved to the title's status chain where the standards put
those -- which is what took the map off the gallery's pre-existing
overflow list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 14:56:42 -04:00
Jean-Pierre MartineauandClaude Opus 5 5a9015662b F-key lane: a directional pair rises to the right, like a rocker
Where two adjacent chips are opposite ends of one axis, the up/in/more end
now takes the right slot: the map reads Zoom - then Zoom +, the shared lane
Bottom/Top and Page down/Page up, and the chat's relabelled jump pair follows
with Latest/Oldest. The actions move with their labels, so one handedness
carries across every screen and F1 always means the near end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 03:15:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 eed7f6b38e Map: keep the screen and one screen of history, drop the rest
The map's decoded-tile dict was never evicted from, so it grew for the
life of the screen with every tile the view had ever touched. Measured on
the Lyra, a decoded tile is ~0.9 MB resident (62 bytes a point) against a
device with ~100 MB in total and, in practice, under 20 MB free -- so a
long enough pan was an out-of-memory kill waiting to happen. It predates
the caching work, but making tiles cheap to load makes it easier to reach.

The budget is relative to the view rather than a fixed number, so it
scales itself: twice what the current viewport needs, floored at 8. That
lands around 8-12 tiles on the PicoCalc's 53x26 and grows on a desktop
terminal, without either one needing to know about the other.

Two details worth keeping. Tiles on screen are moved to the end of the
order before the trim runs, so eviction starts at the far end and can
never take something being drawn. And only entries holding geometry are
counted or dropped: a None is the memory of having *asked* -- the tile was
absent, or the source never answered -- which costs a dict slot rather
than a megabyte, and dropping it would buy nothing back while costing a
re-request on the next repaint.

Getting this wrong is now cheap, which is what makes a tight budget the
right call: an evicted tile comes back from the decoded cache in ~25-50 ms
having already been parsed once, so the cache is trading a little memory
for instant back-panning, not for correctness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:19:04 -04:00
Jean-Pierre MartineauandClaude Opus 5 790e17932e F-key lane: dim a slot whose action isn't available
The PicoCalc lane replaces the footer hint outright, so it inherits the
hint line's standing rule — never name a key that would do nothing. Until
now it named several: Retry with nothing unacknowledged, the paging pair
on a body that already fits whole, and, worst, the map's End, bound to
nothing at all while PgUp/PgDn were labelled as paging when they zoom.

An FPair now carries enabled/opp_enabled per bank. A cleared slot keeps
its label and drops its fill, landing in the same muted "nothing to
press" class as an unassigned one — so the key still reads as what it
would do, it just plainly isn't live. The dimming stays presentational:
action_for still resolves a dim slot, and the screen's own handle()
remains the authority (it no-ops), so a lane built from stale metrics can
never swallow a key that works.

Applied where a screen can tell honestly: default_lane(nav=…) for the
shared slots (result windows and the dashboard gate on content_overflows,
the chat on having a transcript), the chat's Retry on there being an
unacknowledged direct message, and the map gets its own lane — Reset,
Zoom +, Zoom - — with F2 left unassigned rather than dead. The chat's
regular-platform hint drops ^R on the same gate.

compose_base now takes the lane as a builder and calls it after the body
renders, so those gates read this paint's scroll metrics instead of the
previous paint's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:01:01 -04:00
Jean-Pierre MartineauandClaude Opus 5 63cd4e5f96 Performance: SQLite WAL, lighter startup, per-platform tick rates
PicoCalc plan phase P2, driven by measurements taken on the Lyra as a
paired A/B (git archive HEAD vs the working tree, run side by side
against the same venv).

The headline is the database. Every packet the monitor hears is one
durable commit, and on the SD card each was costing 33.78 ms of fsync.
db._tune() opens in WAL with temp_store=MEMORY: 0.59 ms per commit, a
54x cut. synchronous=NORMAL is requested only once WAL is confirmed
engaged - the pragma returns the mode it settled on rather than raising
when a filesystem refuses it - because under WAL it risks the last
transactions to a power cut, and under the rollback journal it risks the
file itself. This device runs on a battery pack.

Startup: bleak/dbus-fast were already lazy; the real finds were two
cheap *values* dragging heavy subtrees. core/connection.py imports one
constant from core/channels.py, which imported PyCryptodome at module
level (224 ms of CPU-feature probing per run) - now deferred into the
two functions that decrypt. And tools/map.py's register_cli, which runs
for every tool at boot, reached into ui/map_screen.py for a --fraction
default, dragging the basemap's urllib.request -> http.client -> ssl
behind it; DEFAULT_VIEW_FRACTION moves to core/geo.py (pure arithmetic,
already on the boot path) and is re-exported so existing importers are
untouched. 26 modules and 316 ms of import self-time gone.

Per-frame: the corner glow only ever recolours truecolor foregrounds, so
on a 16-slot console it scanned every line of every frame to hand back
the identical list - skipped outright where effects are off, along with
the battery blink and the braille spinner cycle. _has_wide_glyph answers
from the platform when no emoji can be drawn instead of scanning the
composed frame (6.5-9.1 ms per repaint on its own). The header built its
segments twice to measure the compact separator, on exactly the narrow
terminals that take that branch every frame; it now builds once and
measures arithmetically. Together: 101.2 -> 73.2 ms per frame at 53x26,
151.8 -> 110.4 at 53x40, and 72x24 unregressed at 91.7.

That finally gave numbers to set the clocks against. refresh_interval
becomes per-platform tick_s (2.0 s on picocalc: a 1 Hz idle tick alone
was 7-11% of a core doing nothing, and only the header's per-minute
pulse drifts, since screens push their own repaints via invalidate).
The 0.12 s spinner was untenable - 84% of a core at 53x26, and at 53x40
it did not fit in its own interval at all. Five duplicate
_SPINNER_INTERVAL = 0.12 constants across the UI collapse into one
platform-aware spinner_interval() beside the Spinner it paces.

The header refactor was verified equivalent over 540 renders, plain text
and style spans both. The span check earned its keep: the first cut
passed style= to the Text constructor, which makes it the object's base
style and blankets everything appended after, so the version string
would have rendered brand-under-muted. test_header.py only asserted on
.plain and never saw it; it now pins the styling too.

tests/test_performance.py pins each contract as behaviour rather than
timing, including a subprocess guard that fails if Crypto, the network
stack, or the map screen ever creep back onto the startup import graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 03:40:42 -04:00
Jean-Pierre MartineauandClaude Fable 5 2d6e20ba2e Node detail: fold the vitals into an Info tab
The identity block was eating the stage's vertical budget on every tab.
The pinned chrome shrinks to the one-line identity header + tab strip;
the vitals rows (key, heard, packets, signal, where), the location
preview, and the Open full map / Time machine actions all move into a
new Info tab (replacing the Map tab — the preview now grows into
whatever rows the tab spares). The Routes tab spends the freed lines on
its graph and route list, and the strip costs a single line of air (the
graph's own top padding provides it).

Opening the full map from the page now also seeds the map's find filter
with the node's name, so it opens centred on the node *and* lit among
the rest — exactly as if the user had typed the find.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 09:59:15 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 0e746f2722 Node detail: open the full map centred on the node
The Map tab's inline preview centres on the node's own fix ("… centred here"),
but its "Open full map" action called open_map with only the markers — so the
full map fell back to the persisted global view (wherever the map was last
left) or a fit-to-all-nodes frame, landing somewhere other than the node the
preview had just shown.

open_map gains an optional focus=(lat, lon): given it, the map opens on that
point at the preview's zoom (min(13, max_zoom)) and — mirroring how
LocationPickScreen uses its transient `initial` — deliberately wires no
on_view_change, so a focused peek never overwrites the user's saved global
map_view, even as it is panned. The node detail passes the node's own
coordinates, the same spot the preview is centred on.

Tests drive the real open_map both ways: focused (centres on the node, leaves
the saved view untouched after a pan/zoom) and plain (still restores and
persists the global view).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 23:15:14 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 62a2d76e8c Node detail: a full-screen page for each contact, reached by Enter
Pressing Enter on a contact now opens its whole story on one page instead of
a bare row: identity (name in its hue, key with the routing hash lit, type,
first/last heard, packet tally, reception SNR/RSSI), an inline location
preview centred on the node (a small static basemap via the new MiniMap), the
observed routes to it, and an action list (trace armed with the suggested best
path, open full map, time machine). Contacts owns the show/open/reshow loop;
topology.suggested answers "the best evidence-backed way to reach this node",
wired ahead of the firmware route in the trace arming precedence. Timemachine
and Trace grew the entry points the page opens.

Four fixes fell out of driving it on real hardware:

Slow open. Each located-node page built a fresh BasemapSource and resolved its
TileJSON -- a blocking HTTPS round-trip (~100ms, up to the 12s timeout on a bad
link) -- on every open. Hold one source for the session (ctx.basemap_source),
shared by the map, the picker, and the preview, and warm it behind the menu at
login, so the resolve is paid once, off the navigation path.

Black map. A companion advertising nonsense coordinates (Homestead reports
lat -97, lon -1042) flung the viewport off the world, leaving the map a screen
of empty/water fill that reads as solid black -- both from "open full map" and
from filtering to it and framing. The old guard only caught the 0/0 no-GPS
null island; the new geo.usable_fix() rejects any out-of-range fix too, applied
to the map markers and the location preview alike. Verified on-device: of 265
located contacts, only Homestead drops.

Path to node. The route/suggest rows render through THE path widget with a
1-byte hash in parentheses (a "name (3d)" tag), so they cross-read hop for hop
against the route graph's byte labels.

Route graph. Draw us on the left and the target on the right (us to target,
"how we reach it", left to right) via a local endpoint swap that leaves the
shared route_graph_style untouched for its other callers, and give the fan a
generous lane step and row budget so a well-connected node's candidate paths
spread into separate, legible tracks instead of a cramped tangle -- the height
stays adaptive, so a one- or two-route node still draws compact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 23:41:52 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 1c89956d08 Map: Enter zooms in on a single filtered node
Pressing Enter with the find filter down to one node recentered on it but
always at the fit's neutral default zoom (14) — no extent to frame — which
could sit more zoomed-out than the current view. Frame a lone match (or
several at one spot) at a street-level closeness instead, capped at the
tile source's max so it never over-zooms onto blank tiles. Multi-node
framing is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:40:36 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 1b20989e6c Filters: ignore leading and trailing spaces
Every type-to-filter surface — the shared select list, the map's node
find, the atlas find, and the path composer's hop entry — now refuses a
leading space (the query never begins with whitespace) and strips the
query when matching (so a trailing space is ignored). Spaces still count
mid-query, so a multi-word name like "Homestead R&D" filters fine; only
"  Homestead" and "Homestead   " are prevented from mattering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:22:47 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 84a2c45f12 Footer hints: one grammar, Esc always last, one key notation
The key hints now follow one sentence shape across every screen:
navigation keys first, action keys next, Esc last — the quit dialog and
TX apply dialog had Esc leading. The Esc verb is now systematic: "Esc
back" leaves a screen, "Esc close" dismisses a read-only floating view
(result windows and the share/key popups switch over), "Esc cancel"
abandons a prompt, "Esc keep" leaves a value picker unchanged. Bare
hints fill in their missing verbs ("↑↓ move", the map's and composer's
naked "Esc"), the confirm prompt says "Enter select" like its button
siblings, and key notation unifies on the compact forms the app already
half-used: ^R/^End for Ctrl chords (chat's "Ctrl-R" included) and ⌫ for
backspace (the map/atlas "Bksp").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 03:59:22 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 d1c8ed73a0 Titles speak one language: Sentence case, no emoji, — for subjects
Every screen and dialog title now follows the same three rules. Titles
are sentence-cased ("Trace — YUL-Poly", "Dashboard — mesh overview",
"Nodes"), never bare lowercase ("trace · x", "nodes", "command line ·
x", "path width", "admin login" and a dozen siblings). An em dash
introduces a title's subject or qualifier while middle dots stay for
chained status atoms ("Map · z12 · 34 nodes · 2 km across"), so the two
separators mean different things instead of being used at random. And
no title carries an emoji — the icon belongs to the menu row; a
content-sized floating dialog whose title emoji the terminal draws a
cell narrower than Rich measures leaves the border short (the bug the
Send advert popup already documented), so the Watchtower, Courier,
Time machine, identity-key, reboot, sync, restore, location, factory
reset, and advert dialogs all drop theirs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 03:55:12 -04:00
Jean-Pierre MartineauandClaude Fable 5 36e8173b5a Map: arrows-only movement, find-as-you-type node filter
Panning is the arrow keys (Shift for fine), zoom is PgUp/PgDn alone, and
reset moves to Home — freeing every letter and +/- so typing can drive a
live name filter instead: matches keep bright white labels and always win
the label contest, everything else dims to bare context glyphs, Enter
frames the matches, Backspace edits, and Esc peels the filter before the
map. The footer swaps to the query while a find is active, the title
counts matches, and the location picker follows the same movement keys
with find disabled (typing there has no job). Hints fit 72 columns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:17:33 -04:00
Jean-Pierre MartineauandClaude Fable 5 768487e456 Polish menus and dialogs: lanes, headers, and a Device actions screen
- Dialog footer hints take the border's hue one shade darker (hint.*
  theme styles), mirroring the brighter title tinting.
- Dialogs get a blank breathing row above and below the body whenever
  it doesn't cost visible content.
- The chat conversation picker gains column headers over its lanes.
- The map screen titles itself "Map", matching its menu item.
- The config editor lays settings out in aligned SETTING / VALUE /
  DESCRIPTION columns under a header line, and with changes staged the
  Apply and Back rows sit together below the blank separator.
- Device actions move out of the config editor into their own screen
  behind a new menu-only device-actions tool (CLI equivalents stay
  under the config group).
- Type-to-filter keeps section headers (and column headers) in place,
  and section jumps now work over the filtered rows too.
- The main menu aligns tool names and muted descriptions into two
  columns, headerless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:10:11 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 99643f52d9 Overhaul the config editor into a modal-dialog experience
Rework the device-configuration screen from a flat list with an
advert-laden "danger zone" into one grouped menu whose every prompt is
a real floating modal popup over a persistent backdrop, matching the
quit dialog. The main menu now stays pushed on the session stack for
the whole editor session, so sub-prompts float over it rather than
replacing the screen.

Settings are staged and applied together (with a dirty-cancel
confirmation); device actions — adverts, reboot, backup/restore,
identity key, factory reset — run immediately behind their own
dialogs. Adverts get a dedicated menu (zero-hop, flood, or share this
node as a QR/URI contact card). Destructive actions gate behind a new
TypedConfirmDialog that requires typing RESET / IMPORT. Reboot hands
off to the reconnect dialog and waits for the companion to return.
Location can be set by pointing at the full-screen map (new
LocationPickScreen) or by typing coordinates.

Make every dialog consistent: a short title in the border, the
instruction as an in-body prompt, and a width sized to its content
instead of a fixed wide cap. TextScreen/SelectScreen/AutocompleteScreen
gain an optional in-body prompt, and those plus ConfirmScreen gain a
dialog_width; the prompt is threaded through the surface and session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:28:42 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 b02e3b9f4b Always render the map in braille, dropping block mode
The block-element fallback halved the vertical resolution and looked
poor, so remove the braille/block toggle entirely. Drops the `t` key,
the persisted map_block preference, and the quadrant-glyph rendering
path; the map now always draws 2x4 braille dots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:18:51 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 bfae10b387 Repaint the map's edge instead of the whole frame, and remember the mode
The braille base map leans on glyphs a terminal font may lack; the terminal
draws each missing glyph as a double-width fallback, which shoves the row and
smears the panel's right padding and border. prompt_toolkit's differential
paint models those cells as single-width, so its picture of the screen diverges
from the terminal and it never rewrites the smear.

The previous fix forced a full-frame repaint every paint while in braille mode,
which the 1s header-refresh tick turned into a visible periodic flicker. Replace
it with a targeted scrub: after a move, force just the two right-edge columns
(padding + border) to repaint by poking a sentinel into pt's cached frame. The
map body redraws wholesale as it pans, so its interior self-heals; only the
static edge needed help. Block mode can't smear, so it scrubs nothing.

Also:
- Persist the braille/block mode (app_state.map_block) so the map reopens the
  way it was left; braille stays the default.
- Blank the frame on exit (drop pt's cached frame once) so the menu drawn
  underneath doesn't inherit leftover braille garbage.
- Reword the t hint to "toggle braille/block".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 22:20:09 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 6b84281d0b Render the map so any terminal font can show it
The braille base map and its labels lean on Unicode a terminal font may not
have: 8-dot braille (U+2840-28FF) that many fonts omit, and place/node names
in scripts or emoji outside a Latin monospace font. A missing glyph renders as
double-width tofu, which both looks wrong and shoves the panel right border.

- Prefer a romanized name (name:latin/name:en) over the local script, and strip
  labels to single-cell characters, so foreign names and emoji node names can no
  longer tofu or knock the grid out of alignment.
- Add a block-element base-map mode, toggled with t: 2x2 quadrant blocks instead
  of 2x4 braille - half the vertical resolution, but glyphs essentially every
  monospace font has. Braille stays the default.
- Zoom with PageUp/PageDown as well as +/-.
- While braille mode is active, force a full repaint each paint so the
  double-width-fallback corruption on the right border is scrubbed on every move.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 21:09:18 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 97557fc9b8 Frame the map's dense node core by default, tunable with --fraction
The default view fitted every located node, so a few distant outliers
(here a node near Toronto, 500+ km from the Montréal core) zoomed the
whole mesh out to a continental scale. Frame the densest half instead —
the nodes nearest the median centre — so the map opens on a useful local
region; the rest simply fall off the edges. Verified against a live
159-node mesh: 0.5 lands on the Greater Montréal metro (~77 km across,
two-thirds of nodes visible) rather than eastern Canada.

Viewport.fit gains a `fraction` argument (default 1.0, unchanged for
existing callers); the map's opening view and `r` reset pass 0.5, and
`meshterm map --fraction/-f` exposes the knob on the CLI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:03:26 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 2ec18138a7 Rename project from MeshTools to MeshTerm
Rename the package directory, imports, brand strings, env-var prefixes,
console-script entry point, and the config/data dir (~/.config/meshterm).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:40:49 -04:00