The label was the last field the wrapper wrote into a caller's spec, and on
a two-key text spec it was the key that forced a rehash. It now rides as an
argument like type, so ui.text writes nothing at all, and a button's padding
and centring are the tree's defaults rather than fields patched in from Lua.
With no builder writing into a spec any more, every spec-less call can share
one immutable table instead of allocating its own. It is frozen with a
__newindex that raises, because reintroducing a write would otherwise leak a
field into every spec-less node built afterwards -- a fault with no symptom
anywhere near its cause. One lua_State exists at a time, so the guard costs
about 100 bytes in total.
ui.spacer passes its spec through rather than copying w and h into a fresh
table, and the style alias on a text spec is dropped for textStyle, which no
caller used.
ui.rebuild now collects before the repaint rather than after it. The collect
was already there and its comment already named the hazard, but the painter
is the very next thing to want a large contiguous block for its band, and it
was being handed a heap still holding a screen's worth of dead spec tables --
a C++ allocation gets no emergency collection the way a failed Lua one does.
Worth 5.6kB of free heap at paint time; the largest block is unchanged,
because the freed specs are small and scattered.
Measured in the emulator, 12 sensors / 131 nodes: live Lua at build end
79.9kB -> 79.7kB and build time unchanged at 52ms. The raw heap figure looks
worse because removing the rehashes also removed the allocation pressure that
had been pacing the incremental collector, so the dead spec tables now sit
uncollected until something asks for them; live usage is what did not change.
ui.lua stripped bytecode 9499 -> 9340 bytes.
A table constructor sizes its hash part to exactly the keys given, so every
field the wrapper added afterwards could rehash the spec -- twice for the
small ones. type and interactive are now arguments, which also keeps the
tree ignorant of what names handlers go by, and ui.label no longer writes w
and h: tree.create already measures the same text, font and style to size a
text node, so Lua was measuring the string twice to say what C derives.
Press styles are keyed by exception rather than one entry per node, which on
a full screen was an array part recording that almost nothing opts out.
Measured in the emulator, 12 sensors / 131 nodes, against the previous
commit: build 74ms -> 52ms, Lua transient +12.0kB -> +5.7kB, free heap at
build end 27936 -> 36140. Tree footprint is byte-identical throughout.
Two behaviour changes fall out. ui.label used to overwrite an explicit w
with the measured text width, so optionlist's fixed 10px marker gutter was
silently variable and is now the width it asks for. tree.create measured a
label in styleNormal while the painter draws it in the node's textStyle, so
a styled label was sized too narrow; it now measures in the style it paints.
build() copied a node's children into a second table so it could nil them
out of the spec, and applyStyle() copied the style keys into a third. Both
existed only to hand C a table it was already holding: tree.create reads
named fields and never touches the array part, so children come straight
off the spec, and create now applies the style itself. That is one C call
per styled node instead of two, and the sparse-style decision is made by
the set mask rather than by a loop over key names in Lua.
Measured in the emulator, 12 sensors / 131 nodes: build 106ms -> 74ms, Lua
transient +18.1kB -> +12.0kB, free heap at build end 18572 -> 27936. Tree
footprint is byte-identical, so the same styles are applied. ui.lua stripped
bytecode 10024 -> 9621 bytes, saved in every app state.
An explicit fill now wins over background, where the Lua version had
background clobber it; no caller sets both.
io, coroutine, utf8 and debug have zero call sites across every module and
app, and each one's tables and closures are live heap in every app's state.
Selective luaL_requiref replaces luaL_openlibs; os stays for one os.date.
Measured in the emulator: VM+stdlib baseline 24.4kB -> 21.3kB live.
Reserve nodes/specs/styles/labelAt/labels at reset() while the heap still
has a large contiguous block. A doubling realloc mid-build needs old+new
buffers live at once and throws bad_alloc on the heap the build itself has
fragmented; the styles vector was the one tripping the firmware's global
new-handler into a reset with ~10 populated cards.
UNVALIDATED: reproduced and fixed in the emulator only. Hardware has ~30KB
less free (NimBLE DMA buffers), so the reserves at reset() may still be
marginal there -- not yet tested on the board.
The one-shot scan kept only the top six devices by RSSI and dropped the
advertisement payload, so a distant beacon lost its slot to nearby phones
and its data was unreachable -- wrong on every axis for reading sensors
that broadcast in their adverts.
Replace it with a continuous observer: observe(filter)/observed()/
unobserve()/isObserving(). BleObservation carries the raw advertisement
bytes for Lua to parse, and BleFilter keeps only adverts matching a
service-data UUID or manufacturer id.
The card sits in a scroll container of its own height over an empty strip of
the same, so the slide is a setScroll() delta on the laid-out tree rather than
a rebuild a frame, and the container scissors the part not yet arrived.
Drag, flick and tap-vs-scroll now live on the scrollX/scrollY flag in
ui.lua, so any scroll box pans with no app code. tree.hit returns the
deepest node by geometry and dispatch bubbles to the nearest handler,
dropping the now-unused CAPTURE flag. keyboard moves in as ui.keyboard,
and embed compiles nested lib dirs to dotted module names.
The runtime kept a back stack, a launcher fallback, an app id and a title
because a teardown destroys the Lua that would otherwise hold them. Only
the first of those is true: everything about where an app came from can
ride in the arguments, and the arguments are the one value that has to
outlive the VM.
So the runtime now does four things -- close the state, load a path, hand
the next state its arguments, defer the swap to a batch boundary -- and
sys.startApp(path, args) is the whole of navigation. Routing, history,
titles and data directories move to the Lua file a firmware boots, where
they can differ per product without a flag on Runtime.
Arguments cross as JSON, encoded while the sending state still holds the
table, so a function or a cycle raises at the call rather than stranding
a launch. start(args) receives the decoded table, or nil at boot, which
is how the entry file knows to open its own launcher.
Removes launch, replace, back, canGoBack, getAppID, getAppTitle,
setAppTitle and getAppDataPath, along with the home and data fields.
LANDSCAPE.md goes with them: it recorded a divergence from firmwares that
have since migrated.
lua-cjson decodes straight onto the Lua stack, so a response costs its
text plus the table it becomes rather than a document in between, and it
brings the encode half that a C tokenizer would have left to write here.
It is a module rather than a global: a global namespace is a contract a
firmware implements, and nothing about this needs a provider. Registering
into package.preload also puts it ahead of the SD-card searcher, so an
implementation cannot be shadowed, and an app that never requires it
never pays for the module.
Depth is capped at 32 through the module's own knobs rather than by
patching the vendored source. Decoding recurses on the C stack and
upstream defaults to 1000, which assumes a server rather than a FreeRTOS
task.
The runtime has always called fields on the table main.lua returns, but
@lua-global declared them as loose functions, so the stubs type-checked
something that does not exist and read as "define a global".
Callbacks are now @lua-app blocks that generate a class: App for the core
contract, TouchHandlers and ButtonHandlers beside the namespaces they belong
to. An app composes what it implements:
---@class PaintApp : App, TouchHandlers
Names follow the rest of the surface: onTouchDown rather than on_touch_down,
with the field names the runtime looks up renamed to match. @lua-field carries
the plain fields (home, data) that were prose in a preamble before.
Namespaces were shared across features: `settings` was written by core, the
panel and touch, and `input` by touch and buttons. That made "does this
firmware implement the whole feature?" a question no pointer could answer.
Each namespace now belongs to exactly one feature or to core, so a feature is
a provider pointer and the compiler validates completeness:
gui, node -> screen, tree, under the screen feature
settings -> screen (rotation, theme), sys (timezone),
touch (calibration)
input -> touch, buttons
Runtime::open() no longer requires a GuiProvider; a firmware without one runs
with no screen/tree globals and reports sys.hasFeature("screen") false.
Rotation is one value again: GuiProvider::setRotation applies and persists, so
an app rotating the panel transiently puts the old value back itself.
A build allocates a spec table per node and drops them all at once, so an
app that scans WiFi right after a screen change met whatever the incremental
GC had got around to. Costs a few ms on a screen change; recovers ~24 KB.
Chrome takes the top of the panel before an app builds anything, and layout
has not run yet when it does, so ui.frame() reports what the mount left it.
Fullscreen was the firmware surrendering a strip it clipped apps out of.
The strip is a sibling node now, so an app that wants the panel is chrome
choosing not to build itself.
Chrome and the app now share a tree, so a screen is no longer something an
app constructs and holds: ui.mount() takes the function that builds the
whole thing and ui.rebuild() runs it again. Building a node after layout
is refused rather than silently resetting the arena under the panel.
The firmware knew four paths and called four globals, so the card could
not change its own layout or put anything around an app. It now loads one
file, and the table that file returns owns the rest: start() mounts the
route, home and data name the tree, and every callback is a field on it
rather than a global the app and its chrome would have to share.
The Lua modules were split between tabs and spaces because nothing pinned a
style; .editorconfig is what lua-language-server reads on save, so the editor
and the tree now agree. clang-format already had a config and left native/
unchanged. Embedded modules regenerate from the reformatted ui.lua.
The controller and the WiFi driver each need a large aggregate allocation,
which a live app sitting on garbage can deny - that is why a failed connect
often succeeded on retry. Both bindings now collect before initializing, and
the tree reserves its node and spec capacity so a build does not reallocate
into a tight heap.
Two columns portrait and three landscape read better than 3/4 on a 320x480
panel. The new disabled palette role sits between face and muted, for a
control that is present but inactive.
ui.lua and hints.lua are compiled to LUA_32BITS bytecode (matching the
firmware's Lua build) and linked into the binary. A new package.searchers
entry checks them as the fallback after the SD card, so a local
/.lua/lib/ui.lua still shadows the packaged one for debugging.
Bytecode is ~40% smaller than source and loads without parsing. A fresh
SD card with no make sdcard now has the platform available.
The root fills the whole panel, so a border sent it through the per-pixel
roundRect path on every draw. The panel background is not a card; cards
that want a border set one explicitly.