evan 5d44234046 perf(ui): pass the label as an argument and share one empty spec
The label was the last field the wrapper wrote into a caller's spec, and on
a two-key text spec it was the key that forced a rehash. It now rides as an
argument like type, so ui.text writes nothing at all, and a button's padding
and centring are the tree's defaults rather than fields patched in from Lua.

With no builder writing into a spec any more, every spec-less call can share
one immutable table instead of allocating its own. It is frozen with a
__newindex that raises, because reintroducing a write would otherwise leak a
field into every spec-less node built afterwards -- a fault with no symptom
anywhere near its cause. One lua_State exists at a time, so the guard costs
about 100 bytes in total.

ui.spacer passes its spec through rather than copying w and h into a fresh
table, and the style alias on a text spec is dropped for textStyle, which no
caller used.

ui.rebuild now collects before the repaint rather than after it. The collect
was already there and its comment already named the hazard, but the painter
is the very next thing to want a large contiguous block for its band, and it
was being handed a heap still holding a screen's worth of dead spec tables --
a C++ allocation gets no emergency collection the way a failed Lua one does.
Worth 5.6kB of free heap at paint time; the largest block is unchanged,
because the freed specs are small and scattered.

Measured in the emulator, 12 sensors / 131 nodes: live Lua at build end
79.9kB -> 79.7kB and build time unchanged at 52ms. The raw heap figure looks
worse because removing the rehashes also removed the allocation pressure that
had been pacing the incremental collector, so the dead spec tables now sit
uncollected until something asks for them; live usage is what did not change.
ui.lua stripped bytecode 9499 -> 9340 bytes.
2026-08-08 12:19:53 -04:00

ESP32 Lua API

A future shared contract for Lua applications running on the ESP32 firmwares in ../slate32 and ../crosspoint-reader.

Lua declarations and shared modules live under lua/; the vendored interpreter and shared C/C++ runtime live under native/. Runtime bindings remain authoritative until this repository is wired into their tests.

Layout

lua/
  api/core/       Required namespace contracts, generated
  api/features/   Optional touch and buttons contracts, generated
  lib/            Executable modules shipped to /.lua/lib, handwritten
  test/           Lua host tests
native/
  include/lua/    Public shared-runtime C++ headers
  src/bindings/   Annotated Lua bindings, one file per namespace
  src/node/       Shared widget-tree painting over GuiProvider
  src/runtime/    Lua state, provider wiring, and timer dispatch
  src/vendor/lua/ Vendored Lua interpreter implemented in C
  test/           Native host tests

Every firmware implements all files under lua/api/core/. sys.hasFeature(name) declares optional features; claiming one guarantees every API and behavior in its matching file or directory. Features compose, so a device may expose both touch and buttons. A panel is the screen feature -- the screen and tree namespaces, including the saved rotation and theme -- because a headless firmware supplies no GuiProvider. Every namespace belongs to exactly one feature or to core, which is why touch calibration is touch.setCalibration() rather than a shared settings namespace three features write to. Display technology is still not a feature: an e-ink GuiProvider flattens a gradient the way screen.color() quantizes to grayscale, and the firmware owns publication and waveform policy on every panel.

The contract is the app-facing Lua API, not the provider C++ interface. Shared binding registrations carry LuaLS annotations; tools/gen_api.py mirrors them into lua/api/. Generated files are committed for editors and checked for drift by make test, so a namespace is documented by the code that registers it, including the callbacks in core/runtime.lua and the feature files, which are generated from the Runtime::call* sites that fire them.

Callbacks are fields on the table an app returns, not globals, so each @lua-app block generates a class rather than loose functions: App for the core contract, TouchHandlers and ButtonHandlers alongside the namespaces they belong to. An app composes the ones it implements (---@class PaintApp : App, TouchHandlers), which is as close to per-device stubs as static declarations get -- what a firmware actually provides is still sys.hasFeature() at runtime.

Nothing under lua/api/ ever runs: it is ---@meta for editors and the drift check. lua/lib/ is the opposite -- real modules that ship to the SD card, so composition like ui.lua and hints.lua changes without a reflash.

Firmware supplies the interfaces in native/include/lua/providers.h and nothing else: the bindings, argument validation, timer identity, and the node tree are shared. A null feature provider is how sys.hasFeature() answers false, and its namespace additions are simply never registered.

The declarations are a clean target, not the intersection of today's APIs. Existing apps and firmwares migrate to it without compatibility aliases. Safe filesystem mutation, app navigation, module loading, and ble are core even where a firmware does not implement them yet. Every app may use require; the entry file points package.path wherever it keeps apps and modules. This repository owns portable shared modules such as ui.lua; firmware-specific modules stay with their firmware. The tree below is a convention of the Lua that boots, not something the runtime knows -- it loads the one path it is given.

/.lua/
  main.lua            the entry file a firmware boots
  apps/<AppId>/main.lua
  data/<AppId>/
  lib/<module>.lua

The runtime owns the teardown and nothing above it. sys.startApp(path, args) closes the lua_State, opens a fresh one, loads a path, and calls start(args) on the table it returns. The arguments cross as JSON, because the table they came from dies with the state that built it; encoding happens while that state still lives, so an argument JSON cannot carry raises at the call rather than stranding a launch.

That is the whole of navigation. Routing, history, titles and data directories are decided by the Lua file a firmware boots, since the only thing that structurally cannot live there is a value that has to outlive the VM -- and the arguments are that value. sys.startApp records intent and returns, because swapping the state inside a callback would free the VM still executing it; the firmware calls applyPendingNavigation() between batches. With app identity gone from C++, SysProvider is down to millis, memory, and isClockSynced.

draw(deltaMs) is an optional frame loop called once after start() and then at most 30 FPS, best effort. The host passes monotonic elapsed milliseconds (0 on the first frame). Timers take Lua callbacks and return cancellation handles.

The firmware decides whether an event reaches an app at all -- jitter filtering, chrome, and debouncing are its business -- and Runtime::call* decides what the app sees. A release fires on_touch_up then the on_touch alias, and a button release fires on_button_up then on_button, so the ordering is identical on every device. Only a failed start() stops an app; every other callback logs through LogProvider and carries on. Calling a feature callback without its provider is a wiring bug and says so.

The firmware commits dirty display content after callback batches and owns e-ink waveform policy; apps do not refresh the panel manually. Apps are fully trusted with the complete core API. Theme application belongs to shared ui.lua; screen.setTheme() only stores the name.

The native library vendors Lua 5.4.8 from GitHub tag v5.4.8 and compiles it with LUA_32BITS. Command-line and upstream test entry points are excluded; luaconf.h carries one documented guard that lets the build flag select 32-bit number mode.

Shared UI

Portable apps normally use the declarative ui.lua toolkit; tree remains the low-level escape hatch. The baseline constructors are screen, box, spacer, text, label, button, custom, and confirm. A screen accepts both touch and physical-button input.

Widgets expose three input-agnostic callbacks:

ui.button{
  on_enter = function(id, x, y) end,
  on_exit = function(id, x, y) end,
  on_click = function(id, x, y) end,
}

Touch-down enters; moving outside exits and moving back enters again; release exits before a release-inside click. Directional focus enters and exits nodes; confirmation clicks. Coordinates are present only for touch. There is no hover API. Built-in buttons usually need only on_click; enter/exit exist for custom visuals.

Apps forward input through screen:down(x, y), move(x, y), up(x, y), and button(name, pressed). These return whether the toolkit handled the event, leaving back and page buttons available to the app.

The selected theme name is global in /.lua/theme. Shared ui.lua atomically persists and applies getTheme, setTheme, and themeNames; it also owns palette application, focus handling, and input dispatch. Hardware-specific widgets stay outside core UI.

Development

nix develop
make api       # regenerate LuaLS declarations from shared binding annotations
make test      # generated-file, Lua module, interpreter, and runtime checks

The shell provides Lua 5.4 for module tests; native checks compile the vendored Lua 5.4.8 with the embedded 32-bit number configuration. native/test/runtime_test.cpp drives every namespace against the fakes in native/test/fake_providers.h, so a binding's marshalling is asserted without a board.

Current Sources

  • LCD: ../slate32/stubs/slate32.lua
  • E-ink: ../crosspoint-reader/data/lua/crosspoint.lua
  • Existing comparison: ../slate32/docs/lua-api-parity.md
S
Description
No description provided
Readme 1.4 MiB
Languages
C++ 75.5%
Lua 17.9%
Python 4.9%
Makefile 0.9%
C 0.5%
Other 0.3%