Commit Graph
419 Commits
Author SHA1 Message Date
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 57386fe879 Repaint: we already composed the frame - stop having it read back
Every paint handed prompt_toolkit a finished ANSI frame and let it parse
that back into fragments, write all ~1400 cells into a fresh Char grid,
diff the grid, and re-emit ANSI. Measured on the PicoCalc that round trip
is 70-125 ms, and it is paid in full whether the frame changed by one row,
by all of them, or not at all - the 2 s header tick recomposes an
identical frame and still pays it.

Since the frame is already rows of text, FastRenderer compares this
frame's rows against the last frame's and rewrites only the ones that
differ. Opt-in for now (MESHTERM_FASTRENDER=1) and deliberately narrow:
a floating dialog or the busy overlay is placed by pt's float containers,
so those paints fall straight through to the stock renderer.

The erase has to lead the row, never follow it. A row that exactly fills
53 columns leaves the cursor in the last column with wrap pending, and an
erase-to-end there clears the character just written - the last cell of
every row went missing until the clear moved in front.

Alongside it, three costs that were pure waste on this platform:

  * The redraw was postponed up to max_render_postpone_time before it
    even started - a flat 3 ms on the Lyra, 10 ms on desktop, on every
    key. That default guards a UI whose own output floods it with
    invalidations; here they are keystrokes and a 2 s tick.
  * The splash rendered its body twice per paint - once to measure the
    box, once to fill it - and ran the corner glow ungated, walking every
    line to hand back the identical list on a console with no truecolor
    to blend. compose_dialog had the same ungated glow.
  * A tile's decoded layers were re-read from the SD card on every frame
    (~96 ms each), so panning one dot re-read every tile that had not
    moved. They are held in RAM now, bounded at a viewport plus its ring.

On the device, driven over the real radio: main menu 115 -> 40 ms,
Contacts 155 -> 73 ms, Dashboard 213 -> 117 ms, leaving a screen
375 -> 103 ms. Console readback confirms the frames are byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 01:29:24 -04:00
Jean-Pierre MartineauandClaude Fable 5 2d18c45917 Saved settings: the position rule, proven over a scripted device
The offer flow end to end: a device reporting the 0/0 null island
(exactly what SELF_INFO carries for unset - signed microdegrees over
1e6, verified against the meshcore reader) gets its remembered fix
written back silently; a real conflicting fix still prompts; mixed
drift splits between the silent restore and the prompt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 00:10:53 -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 b83c9005c7 Charts: the mirror returns where the frame affords it; Rhythm seeks its finest slice
The right-gutter marks come back on the desktop (redundant but nice)
and the console keeps the bare tick - axis_chrome is the one number a
caller budgets, platform-bound like the rest of the chrome. The Rhythm
ladder widens to 1/5/10/15/20/30/60-minute slices, finest that fits:
the desktop keeps its 15-minute sweep at 72 cols and earns finer on a
wide terminal; the mesh page re-slices one minute-grid scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 00:10:53 -04:00
Jean-Pierre MartineauandClaude Fable 5 70fb0ceea3 F-key lane: the F3 toggles were better - restored
The per-item chips (node page F1 Info / F2 Routes, Time machine
F1-F3 spans) revert to the single next-thing toggle on F3, per JP's
call. The PicoCalc window ring still stops at 30 d; its F3 chip just
cycles the three spans that remain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 00:10:37 -04:00
Jean-Pierre MartineauandClaude Fable 5 f5244eb4f5 Tests: an inherited NO_COLOR silently blinds every colour assertion
Rich honours NO_COLOR by stripping colour while keeping attributes, so
a shell that sets it (agent harnesses do) fails the suite spuriously.
The autouse fixture now deletes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 19:12:14 -04:00
Jean-Pierre MartineauandClaude Fable 5 de17cee757 Saved settings: never trade a set position for an undefined one
A no-GPS device that restarted reports no usable fix at all - an empty
slot, not a competing value - so a drifted position restores silently,
the channel-replay rule, and only real conflicts reach the operator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 19:12:14 -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 Fable 5 3e7a1893a4 Time machine: spans on F1-F3, and a Rhythm that bends to the width
The 15-minute slices outran the PicoCalc; the chart now takes the
finest of 20/30/60-minute slices that fits (the mesh page re-slicing
one ten-minute base scan), and every section lays out for the single
left gutter. The window ring rides F1-F3 as direct chips - 24 h, 7 d,
30 d, the span on screen dimmed - and the handheld skips all time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 19:12:02 -04:00
Jean-Pierre MartineauandClaude Fable 5 d00c6349eb Braille axis: the right scale was only an echo of the left
Marks now tick the left gutter alone and the right edge closes with a
bare border, handing its cells to the chart. Deep tallies compact to
k/M marks, and the gutter sizes to the widest mark actually printed -
a 1.5k peak reads 1k while a lower tick still lands three digits wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 19:12:01 -04:00
Jean-Pierre MartineauandClaude Fable 5 192179d79f Node page: a tab is one press - F1 Info, F2 Routes
The F3 next-tab toggle gave way to one chip per view in strip order;
the tab on screen dims (its key would change nothing this paint), and
Tab/Shift+Tab still cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 19:11:45 -04:00
Jean-Pierre MartineauandClaude Fable 5 3b075149ce Node page: the PicoCalc trades the strip's air for stage rows
The blank line over the tab strip and the one under it are desktop
luxury; on the handheld both go to the stage - the route graph's
ceiling, the location preview's growth room. Bound at platform-switch
time alongside the borderless frame, never branched per paint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 19:11:45 -04:00
Jean-Pierre MartineauandClaude Fable 5 6a79ec5b5e Colour marks an identified node: unknown hashes go grey
A hash whose node nobody can name reads whole in node.unknown — path
lines, key lanes, the walk, the packet card, the arrivals — never in a
hue its bytes would derive, exactly as the path graph already drew it.
highlighted_hash gains a known flag; the two path widgets decide it from
their resolver, and the node page threads it per hop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 19:11:12 -04:00
Jean-Pierre MartineauandClaude Fable 5 05f3dc9bc6 Top jump: surface the page head, not just the first selectable row
Shift+F5 (and Home) used to park the first choice under its pinned
heading copies; now the scroll goes with it, so the headings and their
preamble come back for real — on the select lists and the node page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 19:11:00 -04:00
Jean-Pierre MartineauandClaude Fable 5 6787aace3c F-key lane: a directional pair rises toward its outer key
F4/F5 keeps up on the right; an F1/F2 pair puts up on F1, so a select
list reads Sect ↑ then Sect ↓ — the hand anchors on the lane's edge and
rocks inward, whichever side the pair lives on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 19:10:50 -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 e00a0529fa Node page: the key is a viewport you slide, not a line that wraps
A public key is 64 hex digits; the Info tab's value lane is 63 cells at
the 72-column standard and 44 on the PicoCalc. So the key never fit, and
wrapping it spent a whole second line on the handful of digits that fell
off — a line the location preview then had to give up.

It holds one line now and scrolls with ←→, the same step the route
pathline one tab over uses. The marks that say "more this way" sit
beside the window rather than over its first cell, so every window the
lane can settle on starts on a byte boundary: the step is four whole
bytes, and a key read from a mid-byte cut spells it wrong. The shift
clamps against the width actually being drawn, to the first step that
brings the tail into the lane, so scrolling can never run off into empty
lane and a resize only pulls an out-of-range shift back in.

The lane is pinned chrome, not a cursor row — walking down to the action
rows leaves the reader's window where they put it; only leaving the tab
drops it. Both the keys and the footer atom naming them are gated on the
key actually overflowing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:02:00 -04:00
Jean-Pierre MartineauandClaude Opus 5 81e58803ee Heard: our own ears outrank the sender's clock
A contact's heard time came straight from the firmware's last_advert,
which the advertising node stamps with its own clock. That makes it
hearsay, and advert_time can only refuse a stamp from the future — a
node whose RTC runs days behind reports a plausible-looking time that
never catches up, so the lane sits at "4d ago" while we are receiving
from it right now. Our own history, stamped when we actually received
something, could only fill the gap when the device reported nothing at
all.

So merge instead of fill: take the later of the device's time and ours.
A device time still wins whenever the firmware caught an advert we never
recorded, and we only override it with proof of a later reception.

A direct message now counts too. In this app's lexicon "heard" means
received from, and a message is received from its sender — but a DM
never touches last_advert and is stored as a message, not an
observation, so a node we chat with daily could read stale and be swept
by the purge ladder's quiet rungs while talking to us. Read from the
messages table rather than recording an observation: a routed message's
SNR belongs to its last relay, which is why heard_nodes excludes packet
rows, and counting it would inflate a lane that means overheard traffic.

The merge runs on every contacts fetch now, so both halves are GROUP BY
queries that aggregate in SQLite and yield one row per node, instead of
heard_nodes streaming all history into Python to build stat objects.

Node detail takes the merged contact time, so it and the contact list
can no longer disagree about one node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 03:49:46 -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 5268892780 Map: buildings as a texture, because a filled dot is an eraser
Draw the `building` layer at the top two zooms, stippled to every other dot.
On a braille canvas a dot is one bit, so a solid fill does not shade a region
— it lights every dot in every cell it covers and takes the street grid down
with it. Downtown becomes a wall of ⣿ with the roads gone. The stipple reads
as tone instead, and leaves room in every cell for a road to stay a legible
line through it. Outlines fail differently: at one bit a building's edge and a
road are the same mark.

The lattice is aligned to the canvas rather than to each polygon, so
neighbouring footprints share it and the texture doesn't shimmer at their
seams.

Below the gate a typical footprint is about two dots across — under half a
cell — and the layer collapses into a haze, so it stays off.

Each footprint is rejected on its own tile-local bounds before a single point
of it is projected. Without that, a downtown tile's 2,704 footprints cost the
frame 2-4x (87 -> 258 ms at z15, 82 -> 294 ms at z16, measured on the fixture);
with it, +7% at z15 and +14% at z16, and flat in zoom. Widening DRAWN_LAYERS
re-stamps the decoded sidecar, so this costs one re-decode per tile and no
re-download.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 02:44:30 -04:00
Jean-Pierre MartineauandClaude Opus 5 fd312db8b9 Map: name the street where you can see it, not where it begins
Street names got rarer the further you zoomed in, which is backwards.
A line label anchored to the midpoint of the feature as the *tile* drew
it -- a fixed geographic point, not a point in your view -- and the
tighter the view, the less likely that point was still on screen. Of the
265 named streets in the Montréal fixture, 37 anchors landed on a 53x26
canvas at zoom 14 and 6 at zoom 16, of which 2 survived collision.

The line is now clipped to the canvas first and the label goes on the
longest piece that survives, so the anchor is always somewhere you are
actually looking. Clipping rather than testing the endpoints matters:
at high zoom a street often crosses the whole view with both of its ends
outside it, and that is exactly the street you most want named. The
next-longest pieces are kept as alternates and tried in turn when the
first spot is already taken, so a street whose middle sits under a node
marker still gets named further along. A feature with nothing on screen
queues nothing at all.

That made a second bug visible: OpenStreetMap splits a long street into
several named features, and once each one anchored to its own visible
stretch, René-Lévesque was drawn twice on one screen. A name is now drawn
once per frame -- on a terminal's worth of columns the second copy only
ever takes space from a street that has none.

Zoom 16 goes 2 -> 6 street names at 53x26 and 2 -> 8 at 72x24; zoom 15
goes 10 -> 13. Waterway labels take the same path and improve with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 02:10:38 -04:00
Jean-Pierre MartineauandClaude Opus 5 71e979eadb Tile decode: decide the layer once, on the way past
The narrowing walked each layer twice -- once for _layer_head to read its
name, once to decode it -- which is free when there is a lot to skip and a
straight loss when there isn't. Measured against a cache spanning zooms
2-14: low-zoom tiles carry no buildings, housenumbers or POIs at all
(a z10 tile is 98% drawn layers, a z2 tile 100%), so the whitelist saved
them nothing while the extra walk cost 16-18%.

The name is field 1 and in practice the first thing written, so the walk
already knows by then whether the rest is worth keeping; from there an
unwanted layer is stepped over rather than copied out. Field order isn't
guaranteed though, so the flag is only an optimisation -- the decision is
the check after the loop, which is right however the fields arrived and
also covers a layer declaring no name at all. _layer_head goes away.

On the regular platform this turns a 0.97x into a 1.03x on low-zoom
tiles and 1.00x on the largest, while urban z14 tiles keep their 3.19x;
the whole 293-tile cache goes 1.4x -> 1.6x. On the Lyra the median tile
is 3.24x and the largest 4.85x, both up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:46:32 -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 504ee37b9a Tile cache: write the decoding down, not just the bytes
The layer narrowing cut what a tile costs to parse; this stops us paying
it twice. Decoding is a pure function of the bytes and the layer set, but
the cache only held the bytes, so every session re-parsed the same tiles
from scratch -- and parsing is the single most expensive thing this app
does.

A decoded/ sidecar now sits beside the raw tiles holding the parsed
layers, via marshal: stdlib, C-speed, and it understands the plain
tuples and scalars a decoded tile reduces to. Deliberately not pickle --
this reads a file off disk, and marshal cannot be talked into importing a
module or calling a constructor.

The sidecar is derived data throughout, and the design leans on that. It
is never what makes a tile "known", so losing it costs only the decode it
was saving. Every way it can fail to be what this build expects -- bumped
wire version, a Python whose marshal differs, a truncated file -- reads
as a cache miss rather than an error. It carries a stamp of the layer set
it was decoded under, so widening DRAWN_LAYERS re-decodes instead of
serving a narrower blob. And because it runs ~2.4x the size of the bytes
it came from, it is the half of the cache that gets a budget: 64 MB, LRU
by mtime, swept once per 8 MB written rather than per tile, since the
walk is slow on the SD card the PicoCalc runs from.

Measured on the device over the 50 real cached tiles: a first session
costs 126 s and a later one 13 s, 9x, with all 50 tiles restoring
identically. Disk goes 7.8 MB -> 7.8 + 19.0 MB. Serialising adds ~13% to
the first decode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 00:45:21 -04:00
Jean-Pierre MartineauandClaude Opus 5 4e7bcacc37 Tile decode: don't pay for the layers the map never draws
Profiling on the Lyra found vector-tile decode dominating everything else
in the app by about two orders of magnitude: a median cached tile took
1236 ms of pure CPU, the largest 3.4 s, against 16 ms of SD read for a
whole viewport. Decoded tiles are memoised for the session, so this is a
first-pan cost -- but it runs on a GIL-bound worker thread, so it starves
the render loop for those seconds rather than running beside it.

Roughly 40% of that work was thrown away. A planet tile ships building,
housenumber, poi and mountain_peak; _draw_tile looks up ten layer names
and none of them is those. So decode_tile now takes the set of layers the
caller actually wants, reads each layer's identity with a cheap header
walk, and decodes the features only for the ones asked for. Unwanted
layers are still returned, named and empty -- which matters, because the
tile source decides whether a cache entry is real by whether the bytes
parsed into layers, and a tile whose every layer we skip must not read as
blank and get its perfectly good cache entry pruned and re-downloaded
every session. That test moves off "has features" and onto "parsed into
layers", which is sound because the other two ways a tile isn't real --
empty and corrupt -- decode to nothing and raise respectively.

Only the decode narrows; whole tiles are still cached, so drawing more
later costs a re-decode and never a re-download.

Measured on the device across the 46 real cached tiles: 183 s -> 51 s,
3.6x, with the drawn layers byte-identical on every one. The median tile
is 1259 -> 353 ms, the largest 3545 -> 759 ms.

_Reader.skip stopped slicing out the bytes it steps over while it was
here, which is most of what makes the header walk cheap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 00:04:59 -04:00
Jean-Pierre MartineauandClaude Opus 5 649a55b558 Tile cache: a flaky link's silence is not an empty tile
Every fetch failure — offline, DNS, timeout, reset, 5xx — collapsed into
None and then into b"", which the loader wrote to disk as "this tile is
legitimately empty". One 12-second Wi-Fi stall on the PicoCalc therefore
poisoned that tile for good: every later pan and every later session read
the zero-byte marker and drew a black square. A body cut short mid-link
did the same by another door, cached and undecodable ever after. The PC's
network simply never flaked long enough to trip it.

So the cache now holds only tiles that decode to real geometry. A GET
returns whether the source actually answered: a 2xx body or a 404 is an
answer, everything else is silence and is never written down, so the next
pan asks again. A body short of its Content-Length is truncation, not a
tile. A tile the source really calls empty is remembered in memory for
the session alone — no repeat requests while the map is open, no
permanent hole on disk.

Cached entries that no longer decode are pruned on read, so a cache
already poisoned this way repairs itself as you pan over it. Writes go
through a temp file and os.replace, since the map and a mini-map share
one source and can fetch the same tile from their own threads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 22:52:18 -04:00
Jean-Pierre MartineauandClaude Opus 5 6f5c61b133 Map: name the four hues the console cannot guess
Water, its watercourses, parks and highways were literal hex, so the
PicoCalc fold picked their slots by nearest neighbour and lost the point
of each one: the dark blue landed on grey, the dark green on black, the
motorway amber on bright red. Same failure the node-type marks already
dodge by naming a style, so these do too — a map.* block in both themes,
resolved through mark_rgb like every other mark.

On the console water is now blue, every watercourse the bright blue one
rung up, parks green and highways brown. Truecolour is unchanged: the
named styles carry the same hexes they replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 22:36:25 -04:00
Jean-Pierre MartineauandClaude Opus 5 87f6dd7dd8 Sort chip: space the arrow like its paging neighbours
"Sort →" reads as the same shape as "Page ↑" two chips over; "Sort→" was
the odd one out. Still six cells, so the lane still sums to 53.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 22:16:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 6c1ddfe689 Sort chip: the shifted half flips the direction
Four columns is a short ring — walking it backwards saved at most two
presses, so it never earned the Shift companion. F8 now flips the sort
direction instead, the other half of the sort and otherwise a Ctrl+arrow
chord away.

The chip names the direction it would give you, not the one already in
force (Sort ▼ while the list reads ascending), drawn in the same triangle
the header lights over the active column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 22:03:47 -04:00
Jean-Pierre MartineauandClaude Opus 5 eaafc87403 Narrow wordmark for narrow screens; move the art out of the code
JP drew a 53-cell MeshTerm at the original's height. It gets the same
colouring: the vertical ramp — white, yellow, light red, red, light
black — with every box-drawing character, shadow row and letter bevel
alike, in the light black.

The art moves to meshterm/assets/. It was sitting in meshterm/ui/ among
the modules, which is the wrong place for a file nothing imports; it
still ships inside the package, since the splash reads it at runtime and
an installed venv has no repo to read from. scripts/colour-logo.py paints
a .txt into its .ansi, so the palette is stated once in code instead of
retyped into escape sequences by hand, and re-styling stays a text edit.

Which mark is drawn is a question about the *screen*, not the platform: a
53-column console and a desktop terminal dragged narrow have the same
problem. load_logo(cols) answers it — widest that fits — and
compose_startup asks, since it is the only place that knows the width.
Below the smallest mark it returns nothing at all; no banner beats a torn
one. A test now draws the real wordmark at each platform's width, which
is the gate the splash never had (it is the one screen the gallery
doesn't cover, which is how a 71-cell mark shipped to a 53-cell console).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 21:53:28 -04:00
Jean-Pierre MartineauandClaude Opus 5 5aeb034c06 Heat: seven steps on human boundaries; recolour the wordmark
JP's spec for the heard-age scale, replacing the four-step ladder: white
under 5 minutes, then yellow at 5m, light red at 1h, brown at 1d, red at
1w, light grey at 1mo, and one cold grey for over a year — which is also
"never heard", since past a year the distinction stops earning a colour.

The `heat.*` styles are renamed for what they describe: a node heard
eight minutes ago is `heat.minutes`, one heard eight days ago is
`heat.days`. The old hot/warm/cool/cold read as temperature but had to
carry an age, and at seven rungs that stopped being sayable. The steps
themselves move into a table, so the ladder is one thing to read.

Three of the seven land in the VT's dim bank, so they say `not bold` — a
selected row would otherwise jump them a rung (brown to yellow, red to
light red), which is the one way a heat colour can lie about an age.

The splash wordmark's vertical ramp becomes white / yellow / light red /
red / light black, each picked to quantize onto exactly one palette slot
(15, 11, 9, 1, 8) while staying a warm fade in truecolour. Its rows are
art rather than renderables, so they had never passed through the fold
and the console was downsampling them on its own — they do now, which is
what makes the slot choice hold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 21:16:46 -04:00
Jean-Pierre MartineauandClaude Opus 5 d79d85b7ee An unknown node is light grey, and bold stops recolouring it
Two reports on the PicoCalc's message paths, one root cause each.

The graph drew an unnamed hop's hash — and the sender's name — as white,
the "you" colour. MapCanvas emits its own bold for markers and labels,
and its colours arrive already quantized to a palette slot; the kernel VT
then draws bold as *brightness*, so anything that landed in the dim bank
was promoted to its bright partner. Light grey became white. A purple
repeater would have become pink the same way. The canvas now drops
emphasis where bold is brightness — the colour is the load-bearing part.

The same trap reaches the theme through inheritance: Rich merges a row's
base style into every span, so a dim-slot span left silent about bold
changes colour inside a selected row. Every dim-slot style now says which
it means — `not bold` to keep its colour, `bold` where the promotion is
the point (only title.muted) — and a test forbids staying silent.

The pathline drew the same hop dark grey, because `muted` collapses onto
the console's dark grey while the desktop's is light. That conflated two
things: `muted` is chrome and may recede a step, but a node we can't
identify is content you can still act on. It gets its own name,
`node.unknown` — light grey, identical to today's desktop muted, so
nothing moves there — and it is what `name_style` returns keyless, what
an unlit `highlighted_hash` wears, and what the `○` ring is.

Also fixes a crash from the previous commit: the graph's label colour
resolved a marker through `parse_hex`, which the type marks' theme names
are not. A repeater relay we couldn't name would have raised.

The gallery's node-detail header spelled a marker hex by hand; it goes
through the app's own styles now, so a palette bug can't hide behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 21:06:42 -04:00
Jean-Pierre MartineauandClaude Opus 5 935b4fb3b6 PicoCalc: nodes colour by key, like everywhere else
A platform's ceiling may change a rule's resolution, never the rule. P3
had picocalc colour node names by *type* instead of by key, so a name's
colour there meant something different than it does on the desktop — the
node's kind, not its identity. Both platforms now run one rule: the key's
first byte picks the hue. The console just spends fewer of them.

node_style gains the binding the heat scale already had. Regular keeps
the full 256-hue wheel; picocalc snaps the same hue to its sixth of it —
the six chromatic bright slots, ~43 first bytes each. It returns the
slot's own hex rather than a color(N), so every downsample on the way out
(Rich's, and the fold's quantizer) lands on that exact slot instead of
guessing a neighbour, and the callers that read an RGB back out of a
style string keep working.

The type marks needed the opposite treatment: their hues are fixed, not
derived, and a naive nearest-RGB match drained the repeater's violet to
grey — a repeater read as an unknown node. They move to the theme's
type.* entries so each platform picks its slot deliberately, and a
braille raster resolves the same entry through theme.mark_rgb, so the
canvas marker and its Rich-drawn twin can't drift.

With names off the type registry, nothing reads it: Platform.name_colour
and core/nodetypes.py go, along with the three reception-layer feeders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:08:31 -04:00
Jean-Pierre MartineauandClaude Opus 5 b42375799a F-key lane: chips name actions, not keys
The lane read as a keyboard: Top, End, PgUp, PgDn — the names of keys
this keyboard doesn't even have, standing in for whatever the screen did
with them. A chip's job is to name the action it performs *here*.

The shared lane now speaks the vocabulary of the one thing it assumes —
a scrolling body: Top, Bottom, Page ↑, Page ↓. The chat speaks its own,
since neither end of a conversation is a "top": Oldest reaches back to
the first message, Latest returns to the newest and the compose line.
The map already got Reset and Zoom ±.

Empty and dim now make different claims. Dim says "a thing here, just
not right now"; empty says "not a thing here at all" — so a channel
chat's F8 carries no Retry, because a channel message is never
acknowledged and there is nothing a retry could mean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:24:52 -04:00
Jean-Pierre MartineauandClaude Opus 5 ba6e92107d Message paths: direct frames never matched at all
The direct-message path view looked for frames of class TXT_MSG. The
meshcore library reports that class as TEXT_MSG — the spelling every
other reader uses (core.frames, the packet viewer's lane, the dashboard's
traffic keys) — so the filter matched nothing, and every direct message
reported an empty window no matter how much of the conversation the radio
had overheard. Its test fabricated rows under the same wrong name, so it
passed against a mesh that can't exist.

With the class fixed, the evidence can also be far better than "±90 s of
every direct frame we heard": a TEXT_MSG frame is encrypted, but its
addressing is not — both ends ride in the clear as one-byte key hashes,
and core.frames has been recovering and storing them all along. The
window now narrows to frames running between this conversation's two ends
in either direction, and the summary bills it as matched by address and
time. A frame whose addressing was never recovered drops out rather than
being counted: paths are evidence, and a hash that isn't there proves
nothing.

Still not a content match — a one-byte hash collides — so the view keeps
its warn-coloured summary and makes no claim it can't back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:21:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 b5f4765743 Chat: message paths need a picked message
^P used to fall back to the last message in the transcript when nothing
was picked. A path is the route one particular message walked, so that
fallback showed the paths of whichever message happened to sit at the
bottom — never something the user asked for, and easy to mistake for the
paths of the message they were looking at.

^P now opens the pick and nothing else. The footer drops its ^P atom
while nothing is picked, and the F-key lane dims F3 Paths on the same
gate, so neither offers a key that does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:03:32 -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 Fable 5 f69d1785b7 Performance sweep: cache the render funnel, the queries, the graphs
Every repaint re-rasterized every row and re-ran every layout; every
screen open re-scanned the whole history. Now work is done once per
change instead of once per frame:

Render pipeline — a content-keyed ANSI cache in render_to_ansi (every
Text/str row app-wide), memoized corner glow, and panel-wrap memos for
the base frame and stacked dialogs, all dropped on a platform switch or
emoji recalibration. Idle-frame compose falls 5-100x per screen
(contacts 5.6->0.1 ms, record card 8.5->0.07 ms, trace 7.6->0.6 ms).

Repository — an observed_at index turns every since-window query into a
range walk (recent_observations 7.5->1 ms); heard_nodes and first_seen
aggregate in one streaming pass by ISO-string comparison instead of
hydrating dataclasses and sorting; node_names groups in SQL (NOT
INDEXED past a slow planner choice).

Per packet — livefeed no longer repaints on events that drew nothing,
the channel list's stats TTL rises above the tick so the repository is
not queried per frame, watch-store alerts batch through the throttled
flush instead of a full-state disk write per new node, and the header's
360-bucket histogram/flags tuples are memoized per minute and packet.

Per open — the resolvers, topology canonical()/display_name(), and
Yen's route search memoize over their frozen inputs; timemachine, chat,
and livefeed drop duplicate queries; the config editor, device actions,
info, advert, and tx-optimize reuse the devstate session cache instead
of re-asking the radio.

Per frame, per screen — dashboard folds five window scans into one
digest; walk caches everything derived from its frozen graph (the
onward-count quadratic included); packet viewer, record dialog, trophy
rows, trace stats/log, and the node-detail route fan memoize on their
frozen inputs, invalidated by revision counters where the input can
move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:14:53 -04:00
Jean-Pierre MartineauandClaude Opus 5 6b13808ec6 Chat: count only conversations the picker can show
The header's unread badge summed every conversation the recorder filed a
message under, but the picker only builds rows from what the *device*
reports — its channel slots and the messageable contacts in its table.
Anything outside that set was counted and unreachable: no row to open,
so no way to clear it. The badge sat there for the rest of the session
saying there were new messages while the Chat list showed none.

The badge is a pointer, not a tally — its job is to send you to a thread
you can open — so it now counts only what the picker can list. The rest
is still recorded to history, just silently. In practice what that drops
is machine traffic rather than correspondence:

  * a direct message from a node that isn't a DM recipient — a repeater,
    room server, or sensor. Every remote-CLI reply from a repeater you
    administer arrives this way, so an admin session left a stuck badge;
  * a direct message from a sender the contact table doesn't hold;
  * a channel message the device has no configured slot for, including
    one whose slot couldn't be identified at all.

Muting joins those under one rule instead of standing alone, and the
device is consulted through the session cache, so a message costs no
extra round-trip. Anything the cache can't answer notifies: a cold or
broken cache is a reason to over-notify, never to swallow a message.

That leaves one real divergence to fix rather than paper over. The
recorder resolves a channel slot fresh per message while the screens
read a session-cached list that only an in-app channel edit drops — so
a channel re-keyed or cleared on another client (the phone app) had the
picker listing the old one all session, and now would have silenced the
new one too. Resolving a slot to a different identity drops that cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:25:45 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 16f0101b74 Add wifi-set: interactive Wi-Fi join script for Calculinux
Writes the PSK straight to /var/lib/iwd/<SSID>.psk (same pattern
calculinux-setup.sh uses for first-boot provisioning) then asks iwd to
join right away. Self-elevates via sudo since that path is root-only.
Deployed to that user's ~/bin on the Lyra device (PATH updated in ~/.profile).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 12:49:43 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 86f0a19cf1 PicoCalc: center the title bar's title
The actual gap this whole thread was chasing: Rich's Panel centers
its title by default (the desktop's bordered frame), but the
borderless title bar always left-anchored it right after the leading
dash. The rule/color work in the prior two commits was real but
beside the point -- the title's alignment was the visible difference
JP was comparing against the desktop app.

The rule now fills symmetrically on both sides of the title, centering
it within the row (minus the trailing arrow space when there's more to
scroll); a title too wide for the row still degrades the same way
(truncated from the right) as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 03:46:20 -04:00
Jean-Pierre Martineau 5e329a8883 Reapply "PicoCalc: title bar rule reads like a real frame's border"
This reverts commit 975317bb2ea1d25159f6aaeebfd1bd57dfe8e293.
2026-08-02 03:28:28 -04:00
Jean-Pierre Martineau 3a927b3d65 Revert "PicoCalc: title bar rule reads like a real frame's border"
This reverts commit 79960fd130db9366b4511df87b934ee692da18d0.
2026-08-02 03:06:02 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 f22e8ebdc5 gitattributes: force LF on Python files too
Deploying a file straight to the PicoCalc/Calculinux device (bypassing
git, e.g. pscp for a quick on-hardware test) copies the local working
tree's line endings as-is. With core.autocrlf=true on Windows that's
CRLF, which git on the device then sees as every line "modified"
relative to its LF-stored history -- blocking git pull there until
the bogus diff is discarded.

*.sh already forced eol=lf for the same reason (shebangs/heredocs
breaking on CR); *.py gets the same treatment now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 02:56:20 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 5a81b15ab5 PicoCalc: title bar rule reads like a real frame's border
The borderless title bar's rule (leading dashes, title, trailing
fill) was styled hint.accent — the muted style theme.py reserves for
text riding alongside a border (a footer hint, a subtitle's "more"
label), not the border's own glyphs. A bordered Panel's border draws
in border_style directly ("accent", already bold in both themes), so
the rule read dimmer than the frame it's standing in for.

The rule now takes "accent" directly, matching a real top border's
weight; the title was already title_style("accent") (matching a
Panel's title) and needed no change. The trailing ↑↓ clip arrows keep
hint_style — they play the same role as a bordered panel's own muted
"more" subtitle, not the border. No corner-glow analogue: that pass
needs a truecolor blend PICOCALC's 16-slot palette can't render
(effects=False), so the rule is uniformly bold rather than fading
from a lit corner the way a real frame's top-left does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 02:52:17 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 53e52db94a Chat: fix F1/F2 both landing on the tail
DEFAULT_LANE's F1/F2 dispatch the plain "home"/"end" actions. On the
chat screen those are already claimed by the compose line's cursor
(Home/End move within the message you're typing, tested behavior) —
handle() has no case for them, so both fell through to the same
"clear the pick, stick to the tail" edit fallthrough, making F1 and
F2 indistinguishable.

ChatScreen.fkey_lane now overrides F1/F2 to dispatch ctrl_home/
ctrl_end instead — the actions that actually walk the transcript —
so "Top" jumps to the first message and "End" returns to the tail,
matching their labels.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 02:29:05 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 2d2652005b Chat: cache each message independently, not a prefix
The transcript cache added in 9f600db cut off at the first "volatile"
message (pending ack or picked) and cached only the prefix before it.
Two bugs fell out of that:

- Deselecting a message never invalidated its cached (highlighted)
  rendering, so the old pick's ❯ stayed visible after moving to a new
  one — cache reuse only checked identity/acked, not whether the
  cached slice was rendered while picked.
- Picking a message near the start of a long transcript shrank the
  safe prefix to almost nothing, so every interaction re-rendered
  nearly the whole history again — performance regressed the further
  back you scrolled, recovering only once the pick returned to the
  tail.

Now each message's lines are memoized independently (keyed on
identity, acked, and whether it was rendered picked) and spliced in
or redrawn one at a time, regardless of position. Grouping context
(day/sender headers) is cheap enough to recompute unconditionally
every render rather than trust from the cache.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 02:14:32 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 599893e03a Monitor: queue observation writes off the event loop
MonitorService recorded every overheard packet — every advert,
telemetry response, and RX-log frame, not just messages — with a
synchronous SQLite commit inline in the event hub's dispatch, on the
same single-threaded loop that drives prompt_toolkit's rendering and
key input. On a busy mesh that stalls the whole UI several times a
second, on any screen, scaling with mesh traffic rather than message
count.

ChatService already avoids this for inbound messages by handing them
to a queue and writing on a separate worker task. MonitorService now
does the same: the hub callback just enqueues the observation and
returns immediately; the run-row creation and the database write
happen on a background worker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 01:52:20 -04:00