14 KiB
slate32 Agent Guidelines
ESP32 firmware (C++/Arduino) hosting Lua apps off an SD card. The Lua platform -- bindings,
the widget tree, app loading and navigation -- is shared with crosspoint-reader and lives in
lib/esp32-lua-api. This repository owns the panel, touch, network and persistence; everything
user-visible, chrome included, is Lua under sdcard/.lua/, entered through main.lua.
Build and Test
nix develop -c make test # gfx tests, this firmware's Lua tests, then the submodule's suite
nix develop -c pio run # firmware -> .pio/build/esp32-32e/firmware.bin
nix develop -c make sdcard # copy shared Lua modules onto the card before flash or emulator
src/host implements the interfaces in lib/esp32-lua-api/native/include/lua/providers.h
and nothing else. A binding, a layout rule or an API declaration changes in the submodule,
not here; lua/api/** there is generated from the C++ that registers it, so run its
make api rather than editing a declaration.
Binding Conventions
Accessors are getName / setName / isName. Bare names are actions (screen.fillRect,
wifi.scan) or pure conversions (screen.color, http.urlencode). A missing getter is fine;
an accessor without a prefix is not.
Every namespace belongs to exactly one feature, or to core. screen panel primitives and
its saved rotation and theme, tree the widget tree, touch the panel's input and its
calibration, buttons the button roles — all four are the screen and touch/buttons
features. Core is sys (process, runtime, timezone), ui widgets and palette, plus
wifi/http/fs/log/timer. ble uses NimBLE-Arduino because Bluedroid cannot
initialize beside the Lua runtime; reserve bt and its full-ESP32 backend for future Classic Bluetooth.
A setter that requires a follow-up call is a bug in the setter. sys.setTimezone()
applies the TZ itself; screen.setRotation() rotates the panel, persists the choice and
re-clips, so there is one rotation rather than a live one and a saved one to reconcile. An app
that rotates the panel for its own purposes puts the old value back (touch calibration does).
The one unavoidable exception is the theme, because C cannot reload the Lua palette —
ui.setTheme() is the seam that pairs them: it writes through screen.setTheme(), then
rebuilds the palette and repaints. Apps call ui.setTheme(), never screen.setTheme().
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. WiFi starts from saved credentials, syncs the clock, then
src/net.cpp stops SNTP and powers it off; an explicit wifi.connect() re-enables it.
The Widget Tree
The tree lives in lib/esp32-lua-api/native/include/lua/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 tree.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 tree.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, by ui.rebuild() and nothing else. It resets the arena,
runs the builder ui.mount() was given, lays out and repaints. Building a node outside that
is an error rather than a silent reset: chrome and the app share one tree now, so a stray
ui.box{} would drop the bar along with the screen underneath it. Handles from the previous
build are dead; update a live one with ui.setText(id, text), which is also the difference
between a clock tick and a rebuild.
Layout has not run while a builder is running, so an app sizing itself to the frame reads
ui.frame(), not screen.getHeight() -- the panel is not the box the app was given. Whatever
mounts the tree sets the difference with ui.setInset().
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 screen bindings.
Apps
The firmware boots one path: /.lua/main.lua. It loads that file into every fresh state
and calls the table it returns -- start(args), draw, the onTouch* handlers. Where apps
live, what surrounds them and which callbacks an app itself sees are all decided there, in
Lua. Adding a path to C++ is the wrong fix for anything.
sys.startApp(path, args) is the only navigation there is, and sdcard/.lua/lib/nav.lua
is its one caller. C++ tears the state down, loads the path and hands the next state args
as JSON; it keeps no history, no title and no app identity, because those can ride in the
arguments and a value that cannot outlive the VM is the only thing that has to be C++'s.
So nav owns the back stack, the bar's title and /.lua/data/<AppId> -- nav.launch,
nav.replace, nav.back, nav.setTitle, nav.getDataPath. An app calls nav, never
sys.startApp. Boot passes no arguments at all, which is how main.lua knows to open its
own launcher rather than reading a home field the runtime no longer has.
The arguments are encoded while the sending state still lives, so passing a function or a
cycle raises at the nav call and leaves the app running. Anything JSON carries survives,
which is why history is a list of tables rather than a packed string.
An app is a table too: init(arg) for state, node() returning its subtree, and the raw
touch handlers only if it paints its own surface. Handlers are fields, not globals, because
main.lua and the app share one lua_State and globals would collide.
A table is a type, so an app declares the one it fills. SlateApp in main.lua is this
card's contract with its apps; App and TouchHandlers come from the submodule. An app
composes what it handles -- ---@class PaintApp : SlateApp, TouchHandlers -- which is also
the only static record of which features it needs.
One lua_State per app, closed on exit, which is why the heap returns to the same shape
after every launch instead of fragmenting -- Lua's collector never moves an object, so a
long-lived VM beside WiFi's buffers fragments until an allocation fails hours in. Apps
receive init(arg); states share no memory, so what nav put in the arguments is the whole
handoff. nav.setTitle() retitles the bar for a screen within an app, which the bar answers
with a rebuild, because a longer title needs a wider box.
JSON is require "cjson", provided by the library rather than by this firmware. A global is
a contract the firmware implements; a module is one the library already did.
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.
Banded Repaint
The painter composites, apps do not. tree.draw() unions the dirty nodes into one
rectangle and paints it a band at a time: gui.beginBuffer(x, y, w, h) opens an offscreen
16bpp sprite, the tree is re-walked painting whatever overlaps that band, and present()
blits it. The tree is the display list, so a band re-walks it rather than replaying a
recorded command stream, and one push replaces a bus transaction per primitive -- which is
what lets a whole pane move smoothly. Apps draw in screen coordinates and call
ui.invalidate(id); apps/Scroll scrolls with no band code of its own.
A band that overlaps a node paints the whole node clipped to the band, so a node taller
than a band is painted once per band it spans -- including a custom painter, whose Lua
callback is re-invoked per band. Cull inside a long custom painter; a list long enough to
matter wants virtualized nodes (build only the visible rows) rather than one tall painter.
Band height adapts and must. How much contiguous heap exists depends on the app, the
orientation and the fragmentation already there -- an app launch leaves ~48KB, but ~28KB
once its Lua state is live, and a 320x48 band is 30KB. drawBanded halves the band on a
failed allocation down to MIN_BAND before giving up, and a repaint that can allocate
nothing falls back to painting dirty nodes straight to the panel (which is what an e-ink
provider wants anyway -- the default beginBuffer refuses).
A primitive drawing into a band must clip every write itself. TFT_eSprite does not
clip pushImage, and a node straddling a band edge will otherwise write past the sprite and
corrupt the heap -- the failure looks like a hang in a later present(), not a fault where
the write happened. paintRoundRect therefore clamps its fills (fillClip) and draws
corner spans per pixel instead of pushing them. Adding a primitive means doing the same.
Drawing Pitfalls
screen.* coordinate args go through checkInt, which rejects a non-integral float
(108.5) with "number has no integer representation" -- and an error raised inside a paint
or timer callback aborts silently, leaving a blank pane with no log. Floor computed
coordinates with // ((w - tw) // 2), never /.
A continuously repainting pane cannot be photographed: the emulator's capture returns the
last settled frame, so a running animation reads as the previous screen. Assert motion
from a log (scrollY=), and capture with the animation stopped to check the rendering.
Touch and Drag
The firmware fires onTouchDown / onTouchMove / onTouchUp plus the onTouch tap
alias. onTouchMove 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/.lua/lib/statusbar.lua is a subtree main.lua puts above the app, not something
the firmware paints. The tree does the invalidation: each field is its own node, so
statusbar.tick() on a one second timer calls ui.setText() and only a clock that actually
ticked repaints. There is no cache to key and nothing to drop.
Fullscreen is chrome choosing not to build itself: statusbar.setFullscreen() flips a flag
and rebuilds, so the app's node becomes the whole tree. The firmware has no viewport, no
inset and no idea a bar exists -- it never did know when one changed, which is why that
knowledge is all on this side.
Emulator
Full instructions in .pi/skills/test-e32r40t-firmware/SKILL.md. Two things that cost time:
- An app's own
log.infolands before the host's[lua] running <path>, becauseinit()runs insidestartApp()and the host logs the route after it returns. Wait for[lua] info: home readyfirst, thenrunning Home, or a script waits out its timeout. 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.
device.start(path) mounts an app the way main.lua does, minus the bar, and returns its
table; relaunching is how a test gets a clean screen, because that is what the firmware
does. device.invalidated is what a tick repainted, which is how a per-field update is told
apart from a rebuild.