A node was a Lua table of ~625 bytes, of which 21 keys pushed it over a
power-of-two hash boundary and eight were style copies inheritance had
splattered down from its parent. A 400 node screen cost ~250 KB and could not
coexist with wifi's buffers.
The tree now lives in src/ui/layout.h as a 16 byte struct in a flat arena, and
splits by lifetime: Node holds what hit testing and repainting need forever,
Spec holds what only measure/place read and is dropped when layout ends. Style
is sparse and resolved by walking parents, so a node naming no colours costs
nothing. Re-layout rebuilds from Lua rather than retaining the inputs.
401 nodes: 8218 B steady, 21050 B peak
Lua: ~250000 B steady
sdcard/lib/ui.lua stays the toolkit and keeps every constructor signature, but
returns integer handles: 627 lines to 374. Composition, the palette and custom
painters are still Lua on the SD card; only primitives now need a reflash.
BREAKING CHANGE: ui constructors return handles, not tables. Use
ui.setText(id, text) and keep per-node app data in a table keyed by id.
7.7 KiB
slate32 Agent Guidelines
ESP32 firmware (C++/Arduino) hosting Lua apps off an SD card. C++ owns the panel, touch,
network and persistence; everything user-visible is Lua under sdcard/.
Build and Test
nix develop -c make test # C++ unit tests, Lua tests, and the stub check
nix develop -c pio run # firmware -> .pio/build/esp32-32e/firmware.bin
nix develop -c make stubs # regenerate stubs/slate32.lua after any binding change
make test fails if stubs/slate32.lua drifts from the bindings. Regenerate rather than
hand-editing it.
Binding Conventions
Accessors are getName / setName / isName. Bare names are actions (gui.fillRect,
wifi.scan) or pure conversions (gui.color, http.urlencode). A missing getter is fine;
an accessor without a prefix is not.
Namespaces have one job each: gui device primitives and the live frame, node the widget
tree, ui widgets and palette, settings persisted preferences, sys process and runtime,
plus wifi/http/fs/input/log.
A setter that requires a follow-up call is a bug in the setter. settings.setTimezone()
applies the TZ itself; settings.setRotation() applies the frame and re-clips. The one
unavoidable exception is the theme, because C cannot reload the Lua palette — ui.setTheme()
is the seam that pairs them, and apps call that, never settings.setTheme().
Persisted intent is not live state. settings.getRotation() is what the user saved;
gui.getRotation() is the frame being drawn. They diverge on purpose while an app rotates
the panel transiently (touch calibration does). Never resolve one from the other.
Settings live in C++ (src/settings.h) because the firmware reads rotation and calibration
before any lua_State exists, and calibration again on every touch. Lua reaches them through
bindings so there is one writer.
The Widget Tree
The tree lives in src/ui/layout.h, not in Lua. A node is a 16 byte struct in a flat
arena; the same tree as Lua tables cost roughly forty times that, and a long list could not
coexist with WiFi's buffers. sdcard/lib/ui.lua is a wrapper: ui.button{...} returns an
integer handle, so a node carries nothing an app puts on it. Anything an app used to hang on
a node goes in a Lua table keyed by id, which is what on_press itself does.
The split is by lifetime, and it is the whole design. Node holds what hit testing and
repainting need forever. Spec holds what only measure/place read -- requested size,
pad, gap, alignment -- and is dropped by node.dropScratch() when layout ends. Re-layout
rebuilds from Lua (~8 ms, which nobody notices on a rotate) rather than retaining ~20 bytes
a node against it. measure writes the measured size into w/h and place overwrites the
same slots, because the two are never needed at once.
Style is sparse and inherited: a role unset on a node is answered by the nearest ancestor
that sets it, so a node naming no colours costs zero bytes. That is what keeps Node at 16.
Applying a palette is one node.setStyle() on a subtree root, which is how a dimmed region
and the lit dialog above it are one call each.
What is behind a node is derived, never set. bg is the background a node offers its
children to draw text on; the surface an anti-aliased corner blends into is the fill of the
nearest ancestor that actually paints one, or the panel. A dialog layer paints nothing, so
its card blends into the dimmed content two levels up rather than into the lit palette the
layer hands down — make it a style role and the scrim stops at the rounded corners.
A screen is built from scratch. The first node created after a layout resets the tree,
automatically -- an explicit reset per build entry point is a chance to forget one and grow
the arena a screen at a time. Handles from the previous screen are dead; update a live one
with ui.setText(id, text).
Layout is -Wall -Wextra C++ free of Arduino headers, so test/ui_layout_test.cpp runs it
on the host through make test-cpp. Adding a primitive (a paint routine, a layout mode)
means C++ and a reflash; adding composition (ui.confirm, a new card) is still Lua on
the SD card. Custom painting is the seam between them: a custom node paints itself through
the gui bindings.
Apps
One lua_State per app, closed on exit, which is why the heap returns to the same shape
after every launch instead of fragmenting. sys.launch(path, arg) pushes the current
route, sys.replace(path, arg) does not, and sys.back() pops it; history stores paths and
arguments, never Lua states. Apps receive the string as init(arg) -- states share no
memory, so a string is the whole handoff. sys.setAppName() retitles the bar for a screen
within an app.
sdcard/lib/keyboard.lua paints all keys in one ui.custom node; a node per key would add
dozens of nodes and their styles while WiFi already holds buffers. Its geometry (keyAt,
eachKey) takes an explicit rect and no node, which is why test/keyboard.lua can assert
what a tap enters on the host.
A repainted node clears its own box first, and a custom painter is no exception -- the
number page is narrower than the letter page, and the letter page's outer keys survive
otherwise. Press feedback that must not repaint the whole node is on_down drawing one
region and on_unpress restoring it after the 80 ms hold.
Touch and Drag
The firmware fires on_touch_down / on_touch_move / on_touch_up plus the on_touch tap
alias. on_touch_move filters 2px of XPT2046 jitter and nothing else; apps such as Paint
consume it directly so a stroke starts at the first real pixel. Keep ordinary toolkit UIs
frame-sized. If a real long-list use case appears, prefer one purpose-built painter over a
component per row.
Status Bar
sdcard/lib/statusbar.lua paints the top strip; the firmware only clips apps out of it and
calls draw(hasBack) on a timer. Invalidation is entirely Lua's: draw() compares each
field against what it last painted and keys the whole cache on gui.getRotation(),
ui.themeName and sys.getAppName(). Adding a field that can change means adding a term
to that key, never a flag on the C++ side. A new app gets a fresh lua_State, so an empty cache already means "repaint
everything".
The one change Lua cannot observe is an app that painted over the bar while fullscreen —
apps toggle through statusbar.setFullscreen(), which drops the cache. Do not add
invalidation flags on the C++ side; the facts that drive the bar are only knowable in Lua.
Emulator
Full instructions in .pi/skills/test-e32r40t-firmware/SKILL.md. Two things that cost time:
- The launcher logs
[lua] home ready, not "launcher ready". It is/apps/Home, an app like any other. wait-frameis an e-ink command and hangs on this board, andsleepis not a command at all -- a script using either stops silently. Usewait-idle SECONDSbetween captures andwait-logfor everything semantic.- Captures round-trip slower than a fast UI transition, so a screen that appears for under a second is unreliable to photograph. Assert those on the host instead.
Pure Lua logic belongs in test/*.lua against test/fake_device.lua, which is the single
definition of the binding surface for host tests. Renaming a binding means editing that file.
Reserve the emulator for the panel, touch and SD.
fake_device fakes the tree's structure and none of its geometry, because geometry is
asserted in test/ui_layout_test.cpp against the same C++ the panel runs. So a test presses
a control by what it says -- device.tap("Rotation"), device.labelled(prefix) -- never by
a coordinate. device.press(id, x, y) is for widgets with no label to aim at, like the
keyboard, whose own painter decides what a point hit.