Every node-type mark is white now: ● ▲ ■ ◉ were pink, violet, white,
and orange, and the shape already says the type. Each keeps its own
type.* style, so one can take a colour back in one place; the unknown ○
keeps its grey. On the map a crowd in one cell used to glow toward
white, which a white marker cannot do, so that signal is gone.
The border grey goes down to #7c7c7c, which JP picked on a ladder. That
sits almost on faint, so the title bar's live clip arrow is now lit like
the title instead of taking the border's grey. On the PicoCalc the
border is the dark grey slot.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Tailwind slate greys were cold. Each one is now r=g=b at its own
OKLab lightness, so every contrast stays where it was: the theme's
chrome, the path chips, the map's roads, labels and buildings, the heat
tail, and the emulator window. test_neutral_greys fails any #rrggbb
literal that reads as a grey and has a tint.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The star of our node had the hue of our key, set from the self-info at run
time. It is now the white of "you" on each surface: the map, the routes,
the lists, the legends, and the chat. The run-time colour, the device-state
hook, and the style generation of the render cache are removed. A faded
star keeps its grey.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The star of our node was the yellow of the map. SELF_MARK now names a
theme style, you.mark, and the theme writes our key's hue into it when the
device-state cache reads the self-info. The context makes that connection,
so the services still do not import the UI. The map, the routes, the lists,
the legends, and the chat all draw it, and the render cache keys on a style
generation so no old bytes stay. A faded star keeps its grey.
In a chat, a sender or a mention with no key had the light blue keyless
chip. It is now quiet: the grey of plain text on that grey darkened by the
chip rule. Routes keep the keyless chip, arrow mode keeps node.unknown, and
the PicoCalc keeps the keyless pair.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The same rewrite as for the core package, for the screens, the dialogs,
the map, the charts, and the CLI renderers. The glossary words are used
for the screen: the user (not the reader), dialog (not popup), viewport,
list window, highlight, pointer, graph lane, marker, raster, result
screen, and key prefix (never hash for a truncated key).
The rewrite also corrects approximately 70 docstrings and comments that
no longer agreed with the code: rows that no longer exist (Back rows),
counts that changed, renamed constants, wrong cross-references, and
behaviour that changed. Each correction follows the code; no code
changed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Panning or zooming the map quickly on the PicoCalc could stop the whole app
for many seconds. A watchdog run on the device found two causes behind it,
plus the port walk fixed in the previous commit.
Memory. A decoded tile holds 1 to 4.6 MB of RAM on the device (65 bytes a
point), not the 0.9 MB the code assumed. The source kept up to 24 of them,
counted as tiles, and the map screen kept 8 or more of its own on top, so a
few pans and a zoom out filled a 104 MB device. Swap is on the SD card, so
the process then stopped while the kernel read its code back in: 16,000
major page faults and a 15 s freeze in one run. Now the source holds decoded
tiles to a byte budget, one eighth of the machine's RAM (12.8 MB on the
PicoCalc, at most 128 MB on a desktop), weighed by mvt.resident_bytes. That
estimate was fitted against tracemalloc on the device's 157 cached tiles: 1%
under to 9% over there, and 6% under to 21% over on a 64-bit desktop. The
screen keeps only the tiles in view, and takes any tile already in RAM
straight from the source on the paint (BasemapSource.resident), with no trip
through a thread. The prefetcher warms the disk cache (BasemapSource.warm)
instead of filling the RAM with guesses, and passes over any tile already
written down. Tile loads take turns, two at a time, and a tile the view has
left before its turn comes is dropped, not loaded.
CPU. fill_polygon asked every edge about every scanline, which at z7 means
coastlines with tens of thousands of edges: 6.8 s of a 7.6 s frame. Each
edge is now filed under only the rows it crosses, and draws the same dots
(checked against the old fill on 3,000 random polygons, and in a test).
_draw_tile also tests each ring's bounds before projecting it, and skips a
ring wholly outside the view. That changes no dot: 1,500 frames from the
device's tiles match rendering with no culling at all. The one frame that
differed from HEAD was HEAD's own building cull, which dropped a footprint
whose fill rounds onto the canvas edge.
Measured on the PicoCalc with the profile-picocalc map_burst tour (keys sent
at 10 a second, from a pinned z10 view, real radio and database). The new
code ran first, so the baseline had the warmer tile cache:
pans, catch-up after a burst HEAD 1.0-1.8 s now 0.4-0.6 s
longest silence, whole tour HEAD 11.6 s now 0.6 s
whole-process freezes HEAD 8.0, 3.1, 2.1, 11.6 s
now one, 1.6 s (idle pages swapped back in)
swap at its peak HEAD 25 MB now 7 MB
warm raster, z7 / z8 / z9 HEAD 7.6 / 1.9 / 1.8 s
now 0.57 / 0.29 / 0.81 s
warm raster, z10 / z13 HEAD 0.49 / 0.56 s now 0.26 / 0.26 s
Still open: z9 spends 0.23 s drawing lines; a prefetch decode of a heavy z7
tile is still 1.3 to 1.7 s on a worker thread; and pages swapped out while
the map sits idle can still cost a second or two to come back.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A pre-release architecture and QA pass, applied as one change because the moves
share files and only the whole set was gate-tested (2480 passed, ruff clean).
Three new modules pull logic down out of layers that should not own it:
core/channel_probe.py (ChannelSlot, read_channel_slots, probe_channel_slots)
so services/device_state.py no longer imports a screen; services/markers.py
(gather_markers, with MapMarker a core/models dataclass) so two screens stop
importing a tool; core/tracing.py (trace_timeout, path_hash_flags) so
core/connection.py no longer imports services. The routing-prefix width is
DeviceState.routing_prefix_bytes(), replacing five private cross-module imports.
One definition per thing: is_path_hash, the NodeResolver/NameKeyResolver
aliases, TILE_RETRY_SECONDS, haversine_km, marks.SELF_MARK, MENTION; nine
private names that crossed module boundaries got public ones and menus.__all__
lists what other modules import. A preference's default is written once
(ALERT_CAP, DONE_CAP, DEFAULT_VIEW_FRACTION read the registry), the stranded
PREFERENCES doc comment sits on its subject, and three docstrings stop naming
questionary, a library dropped long ago.
The command line gains --version (eager, exit 0, documented, tested) and drops
the -p short form on trace, trace-path and tx-optimize: the root lifts -p as
--profile before a leaf ever sees it, so the leaf's could never work, and a
derived test now checks every leaf against every root short option.
tx-optimize with a one-hop --path is a usage error, exit 2, because nothing
transmitted is not a device failure. Log handlers are closed before they are
cleared (197 unclosed-file warnings gone), chat's fire-and-forget tasks keep a
reference, coroutines use get_running_loop(), the never-awaited MapScreen._load
in a test is awaited, and the CLI sweep passes --no-ble to devices so a test
run stops turning on the Bluetooth radio three times (the suite is 40 % faster).
The about page lists every dependency, and the release skill bumps the README's
download names, which still said 0.2.8.
apply_ops stays in tools/: it and its helpers build report blocks and open two
screens, so moving it to core/ would invert core over ui. The widgets CLAUDE.md
names by their private spellings (_format_age, _NODE_GLYPHS) keep them until
CLAUDE.md is edited alongside.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.
Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.
What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.
The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.
Add this commit to `.git-blame-ignore-revs`, which follows immediately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`E501` across 69 lines, wrapped rather than exempted.
The prose ones — module docstrings in `menu`, `node_detail_screen`,
`pathline`, `pathgraph`, `walk_screen`, `trace_runner` and `test_about` — are
reflowed as paragraphs, so the block keeps its shape instead of gaining one
short orphan line. Nothing they say changed.
The code ones mostly gained a line break inside a call. Where a break would
have read worse than a name, the sub-expression got one instead: `reach` in
`topology`'s quality clamp, `plain_cells` in `pathline`, `numbering` in the
trace list, `listed` in `flash.py`'s refusal, and a small `shape`/`is_strip`
helper in two tests that were comparing or matching the same shape twice on one
line. Four docstring summaries lost a word they were not using.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mechanical tail of the ruff pass, all of it behaviour-preserving.
`E741`: 29 uses of the ambiguous `l`, which reads as a `1` in most fonts.
Twenty-four of them held a rendered line and are now `ln`; three held an MVT
`Layer` and are now `layer`; two were a map label sorted by rank, now `lab`.
Renaming only the flagged line and then re-running `F821` was the check — a
half-renamed comprehension leaves a name nothing defines, so the linter finds
what a careful read might not.
`UP031`: 15 `%`-format strings become `.format` calls. `UP007`: the `CellStyle`
and `LabelT` aliases become `X | Y`; both are annotation-only, so the runtime
value never mattered. `E702`: four semicolon-chained `await` lines in
`test_device_state` get a line each.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ruff check --fix` over the whole tree, ahead of putting the linter in CI:
1977 mechanical fixes, overwhelmingly `Optional[X]` → `X | None` (1186 in the
package alone) and the quoted annotations that `from __future__ import
annotations` already made unnecessary, plus import sorting and unused imports.
Nothing here changes behaviour — the suite is 1885 green before and after.
Four findings were real rather than stylistic and are fixed by hand: a dead
`cells` range in `MapCanvas`, a dead slot dict in the heat-ladder test, a
`ProgressScreen` task handle nobody read, and a tab loop binding a `label` it
never used. The two `F821`s were quoted forward references to names imported
inside their function bodies — now declared under `TYPE_CHECKING`, which keeps
`test_performance`'s local import (it guards the startup import graph) while
letting the linter resolve the name.
412 findings remain and are deliberately untouched: they need a decision about
the rule, not an edit to the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_compose will not place a label under its own min_zoom, and street names
carry one of 15. _draw_tile built them anyway, at every zoom: a named
road's label is the expensive half of it — the line clipped to the canvas
segment by segment, each surviving piece measured, the pieces sorted, and
feature_to_dot called once per vertex, which is the per-vertex function
call the geometry path beside it deliberately inlined away. A central
Montréal tile carries 270 of them.
Measured on the desktop against the committed renderer, one tile at
PicoCalc geometry: 16.8 ms of a 46 ms frame at z13, all of it discarded a
moment later. Below the gate a frame is now 27-52% cheaper across z9-z14,
which on the device is a third of a second off the two zooms the map
most often sits at — it opens on the region fit, and z13/z14 is where a
mesh-sized frame lands.
The zoom is the display zoom, not the tile's: an overzoomed view reads
its ground off a lower tile but is still zoomed in, and still wants its
names. The 15 is a constant now, read by the queue-time gate and the
place-time test both, so the two cannot drift.
Nothing in the picture moves. Rendered against the pre-change renderer at
z9-z18 on three geometries, coarse pass included: every frame
byte-identical, every Ghost raster identical (a label was never in the
raster — that is the overlay). The skipped candidates never reached the
max_labels budget either, since the placement loop continues past an
under-zoom label rather than counting it, and sorted() is stable, so what
does get placed is unchanged. Crossing the gate changes the viewport and
so the ground key, which means a zoom across it always redraws rather
than serving a frame short of its names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_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>
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>
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>
Zooming in reprojected the last frame's ground a cell at a time, which
means a source cell that now covers a 2x2 block of cells was stamped into
all four of them whole. That is double vision: the same 2x4 dot pattern
side by side and one above the other, four times over at two steps. It
reads as a rendering fault rather than as the coarse preview it is, and
it puts the ground up to a cell away from where it really is.
A braille cell is 2x4 dots, so the honest mapping is fixed arithmetic:
each cell of the block shows its own quarter (or sixteenth) of the source,
enlarged. That is 256 patterns by n*n sub-positions and it never varies,
so _magnified() builds it once per factor and the paste reads it with a
single index — the same one lookup per cell the naive version cost, and
none of it on the paint path. _ghost_axis now carries the remainder of the
cell division alongside the quotient, which is the sub-position.
Measured against the real frame that lands a moment later, the stand-in's
dots now sit on the ground's dots 85% of the time at one step (was 83%)
and 64% at two (was 61%) — and at two steps its best fit moved to a zero
offset, from a dot column out.
Reduction is deliberately left alone: a view that zoomed out keeps the
whole source glyph in the one cell it shrank to, because dropping three
quarters of its dots would break every thin line into dashes exactly as
it gets harder to see.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mark_rgb was reached ~30 000 times a frame and Feature.get ~32 000, both
answering the same handful of questions: a tile's colours are a handful
of constants and a handful of feature classes, shared by tens of
thousands of features. The memo behind mark_rgb makes each call a dict
hit, but at that count a dict hit behind a function call is real time on
a Cortex-A7.
Resolve the fixed colours once per tile and memoize the per-class styles
(roads, waterways) into small local dicts; read tags directly rather than
through the accessor. Only the ground raster's catch-up, not the
keystroke — that moved off the paint path already.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
The first P3 (a84abf3) ran on the wrong model and its palette never
reached the TUI: MESH_THEME_16 was written in 256-colour cube indices
instead of the 16 VT slots, and the platform theme pick was wired into
the scripted-CLI console while the real rasterizer stayed hardcoded to
truecolor. Replace it end to end:
- theme._VT_SLOTS: the canonical slot->RGB design (conventional hue
families kept; slots 5/6 become the track/faint slates), MESH_THEME_16
rewritten against color(0..15) with the VT's bold-as-brightness and
dim-bank-background rules pinned by tests. /etc/vtrgb + a boot oneshot
install it (console-font script), OSC fallback when kbd is absent.
- platforms.on_platform: set_platform now re-runs registered bindings,
so theme, rasterizer and widgets swap implementations once per switch
and hot paths read module globals - no per-call get_platform().
- The render-boundary fold (theme.fold_text): one str.translate pass
(NFKD accent folds - the Terminus base has no accented Latin - plus
symbol stand-ins and the width-padded emoji map), an embedded-SGR
quantizer for canvas truecolor, and a ?-at-own-width catch-all that
makes the no-wide-glyph short-circuit true by construction. Applied at
the only two places every visible byte passes: render_to_ansi and
MapCanvas.to_ansi_lines.
- ui/fontset.py freezes the installed font's inventory (dumped live);
the gallery now asserts rendered picocalc ANSI carries no truecolor/
256-colour SGR and no character outside it - the parity gate the
first attempt faked by asserting a dataclass field.
- Font build: nine new 6x12 marks (ellipsis, warn, backspace, shift,
gear, refresh, clock, crosshair, padlock) drawn into donor slots, with
donor misses now fatal and a keeper canary (donating pi once erased
Cyrillic pe - shared slot). Table re-dumped on-device: identical to
fontset.py, 519 = 519.
- Type-based name colouring: registry moved to core/nodetypes (services
no longer import ui), fed from monitor observations, heard_nodes and
merge_contacts. Heat gradient quantizes to heat.* steps on picocalc.
- ui/marks.py: the P2-deferred constant relocation - widgets no longer
drags map_render/mapcanvas/pathgraph for tuples and aliases.
- meshterm specimen: the visual language on one card through the real
funnels - the on-device acceptance screen (JP's request).
- tools/chat: delivered/no-ack state uses the standard check/query
marks, not the banned emoji pair.
Verified on the Lyra: font+palette installed, specimen framebuffer
captures in .claude/plans/. 1259 tests green, 13 xfailed (P6 list).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
A record's dialog showed its route only as a text line; add the fan-lane braille
graph above it so a walk's shape reads at a glance — us starred at both ends,
each relay marked and labelled by its first hash byte, the same presentation the
Message paths dialog draws a delivery with.
Extract that presentation into widgets.route_graph_style() — the glyph/label/
colour callbacks both screens hand render_path_graph — so the two graphs cannot
drift; pass source==self_name to draw both ends as us for a walk. A route that
revisits a node (a boomerang's mirrored return leg) collapses to distinct-in-
order before drawing, since the depth graph can only seat a node once; the route
line below still carries every hop. Move the shared _UNKNOWN marker down to
map_render alongside _SELF/_NODE/_REPEATER so widgets can reach it without a
cycle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Sort contacts alphabetically with our own node pinned first, and add
Heard (relative age) and Pkts (overheard-packet) columns alongside a
per-type glyph and the full path-hash-highlighted key. Drop the boxed
table for a clean header rule and a glyph legend.
Colour each name by a continuous log-scale heat map of how recently it
was heard (white -> yellow -> orange -> red -> grey), and give the
node-type icons a shared, map-safe palette across the whole app: a
yellow star for us, a loud pink node and a calmer violet repeater
(both in hues no basemap uses, so nodes pop more than repeaters),
a white room and an orange sensor.
In the menu the list becomes an interactive screen: left/right pick the
sort column (cyan header + direction triangle), up/down set ascending/
descending, with column widths reserved so toggling never shifts the
row. The scripted CLI keeps a one-shot render via --sort.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>