f7c5cc09ba
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.
138 lines
7.7 KiB
Markdown
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.
|