Commit Graph
27 Commits
Author SHA1 Message Date
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 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 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 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 4aef3e7b82 Tests: one way to read a screen
Eleven files defined the same _plain (plus test_chat's _strip_ansi and
test_timemachine's renderable flavour), and two of the copies had
already diverged into join-without-strip. tests/conftest.plain is now
the single strip-and-join reader — a list of lines or a bare string —
and every file imports it; the local variants keep their names so call
sites stay put (housekeeping H-003).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 23:12:24 -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 380133a6e0 Map: drop 0/0 'null island' fixes so a lone match can't frame all-black
A companion with no GPS lock advertises latitude and longitude both zero.
Contact.has_location treats that as located, so gather_markers plotted the
node at null island in the mid-Atlantic. The default frame (fraction=0.5)
trims the far outlier away, hiding it — but filtering the find down to that
one node and pressing Enter frames only it, dropping the whole view onto open
ocean: a screen of solid dark water fill that reads as all black.

Share the 0/0 guard _self_marker already applied to our own node with the
contact and heard-node markers, so a both-near-zero fix counts as no fix at
all and never reaches the map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:30:12 -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 Fable 5 3fe4e5b4e1 Map: labels take the name hue, ours the you-white
Node labels were coloured by marker type (self yellow, repeater
violet, leaf pink); they now carry the app-wide key-derived name hue —
MapMarker grows a key field threaded from contacts, stored heard
nodes, and self-info — with our own label the pure you-white while the
★ glyph keeps its yellow. Find-filter matches still force full white,
non-matches still dim bare. The CLI legend follows suit (and its SEEN
column header becomes HEARD, per the lexicon).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 01:17:32 -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 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 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
Jean-Pierre MartineauandClaude Opus 4.8 850a1b3f34 Remember the map viewport across sessions
The interactive map now reopens on the centre and zoom the user left it
at, instead of always refitting to the nodes. Adds a generic app_state
key/value table (schema v6) and typed repository accessors; MapScreen
restores a saved view on open and persists every pan/zoom. Pressing r
still refits to the nodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:12:40 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 a9091006eb Brighten map markers where nodes pile on one cell
A braille cell shows a single glyph, so nodes landing on the same spot
hid the crowd behind one marker. Count the nodes per cell and wash the
surviving glyph toward white on a log2 ramp, so a bright marker reads as
a busy cluster. Shape still encodes node type; only brightness encodes
count. A lone node keeps its exact base colour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:41:24 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 8c1c9959e3 Plot contacts on the map and add fine cell-by-cell panning
Drive map markers from the device's contact list, the authoritative
source for a node's name, type, and advertised position, enriching each
with reception detail from passive-monitor observations when overheard.
Contacts advertising 0.0/0.0 (null island) are treated as unlocated.

On crowded maps, draw every marker glyph but place labels highest-
priority first (self, then repeaters, then leaf nodes), dropping labels
that would collide so important ones win the space. Labels now also keep
a one-row vertical gap so dense areas don't silt into a block of text.

Add Shift+arrow / uppercase WASD to pan by a single character cell for
fine positioning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:52:05 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 d652a4e012 Add an interactive braille OpenStreetMap map of mesh nodes
New `map` tool (menu + `meshtools map` CLI) that plots the mesh's
location-sharing nodes over a real OSM street basemap rendered as Unicode
braille — streets, rivers, water, and place names — the mapscii idea, but
able to plot our own nodes and live inside the TUI.

- core/mvt.py: dependency-free MVT/protobuf vector-tile decoder.
- core/geo.py: Web Mercator + a pannable/zoomable Viewport and tile math.
- services/basemap.py: OpenFreeMap vector tiles (free, no API key), resolved
  from the versioned TileJSON, disk-cached, offline-safe (stdlib urllib only).
- ui/mapcanvas.py: colored braille canvas (per-cell priority, line raster,
  polygon fill, collision-avoided labels), emits truecolor ANSI.
- ui/map_render.py: compositor — basemap styling + node markers with names.
- ui/map_screen.py: full-screen screen; wasd/arrows pan, =/- zoom, r reset,
  Esc/q exit; background tile loading. Offline falls back to nodes on a grid.
- tools/map.py: interactive in the menu, one-shot static render on the CLI.

Repeaters are prioritised over leaf nodes via advert type: threaded `node_type`
through Observation/HeardNode and the persistence layer (schema v4->v5, with an
idempotent migration adding observations.node_type). Added
TuiSession.base_body_size() so a full-screen screen can size itself to the frame.

Tests: tests/test_map.py (24) cover MVT decode against a real cached tile
fixture, projection, the canvas, the compositor, offline behavior, and the
interactive pan/zoom/reset logic. Full suite: 261 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:38:39 -04:00