Files
slate32/AGENTS.md
evan f7c5cc09ba feat(ui)!: move the widget tree into C++
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.
2026-08-02 18:48:48 -04:00

138 lines
7.7 KiB
Markdown

# 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
```sh
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-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.