Files
slate32/AGENTS.md
T
2026-08-06 09:05:14 -04:00

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.info lands before the host's [lua] running <path>, because init() runs inside startApp() and the host logs the route after it returns. Wait for [lua] info: home ready first, then running Home, or a script waits out its timeout.
  • wait-frame is an e-ink command and hangs on this board, and sleep is not a command at all -- a script using either stops silently. Use wait-idle SECONDS between captures and wait-log for 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.