evan 1aa3a59ca9 feat: give /.lua/main.lua the whole screen and the app contract
The firmware knew where apps, data and modules lived, called four globals,
and painted a status bar into a strip it clipped every app out of. None of
that was its business, and the viewport made the bar something an app could
neither compose with nor replace.

It now loads one file. main.lua mounts the route inside its own node tree,
so the bar is a sibling of the app rather than chrome painted over it: one
layout, one hit test, no inset arithmetic and no invalidation flags on the
C++ side. Apps and main.lua are tables -- they share a lua_State, so globals
would collide -- and a screen changes by rebuilding from node().
2026-08-04 21:15:21 -04:00
2026-08-04 10:12:38 -04:00
2026-08-04 10:21:45 -04:00
2026-08-04 10:12:38 -04:00
2026-08-04 10:12:38 -04:00

slate32

A Lua app platform for a cheap ESP32 touchscreen. Apps are plain Lua files on the SD card, launched from an on-screen menu, drawn with a component toolkit and themed from the card. The firmware is the runtime; everything a user sees ships as Lua.

Built for the 4.0" ESP32-32E display (lcdwiki E32R40T): ST7796S 320x480 SPI panel + XPT2046 resistive touch + microSD. The Lua API is kept deliberately in step with crosspoint-reader's, which runs the same kind of apps on e-ink — see docs/lua-api-parity.md.

Build & flash

nix develop          # provides pio, make, lua 5.4 and a host compiler
make build           # build
make test            # host tests (see below)
make upload          # flash over USB-C
make monitor         # serial logs
src/
  main.cpp           runtime loop and the fallback screen
  settings.{h,cpp}   /settings.lua persistence
  gfx/               drawing maths, free of Arduino headers so it is testable
  lua/
    lua_app.{h,cpp}  app lifecycle: the lua_State, callbacks, teardown
    bindings.h       shared internals for the binding files
    bindings/        one file per Lua table: gui, sys, input, fs, wifi
    module_loader    SD-backed require, loadfile and dofile
sdcard/              copied to the card: apps/ and lib/
test/                host tests, run by `make test`
scripts/             gen_lua_stubs.py, which writes stubs/slate32.lua
stubs/               generated LuaLS definitions; point your editor here
docs/                lua-api-parity.md, the crosspoint-reader comparison

Copy sdcard/ to the SD card root. The firmware knows one path, /.lua/main.lua, and that file decides the rest: apps live in /.lua/apps/<name>/main.lua, shared modules in /.lua/lib, and the status bar is a node it puts above whichever app is mounted. Home is itself an app; the firmware only draws a fallback screen if it cannot start.

Lua API

/.lua/main.lua returns the table the firmware calls: start(route, arg) mounts an app, draw(deltaMs) and the on_touch* handlers forward events, and the home and data fields name the launcher route and the data path template.

An app is a table too, returned from its main.lua: optional init(arg), node() returning the subtree for the current screen, draw(deltaMs) (~30fps cap), and on_touch_down/move/up plus on_touch (tap alias, fired on release) for an app that paints its own surface. Widgets are dispatched by the tree, so an app built from them defines none of these. Call sys.back() to return to the previous route, which the back control in the status bar does too; an empty history returns to Home.

init receives whatever string sys.launch(path, arg) passed, or nil from the launcher. launch adds the current route to history; sys.replace(path, arg) does not. States share no memory, so one string is the whole handoff; anything structured travels as a Lua literal the receiver runs through load(). An app is named after its directory until it calls sys.setAppName("settings - wifi"), which the status bar picks up on its next tick.

require reads from the SD card: the running app's directory first, then /.lua/lib/?.lua, both set by main.lua.

Module Functions
gui getWidth(), getHeight(), clear(color), fillRect(x,y,w,h,c), drawRect(x,y,w,h,c), fillCircle(x,y,r,c,bg), drawLine(x1,y1,x2,y2,c), drawText(text,x,y,fg,bg), roundRect(x,y,w,h,radius,bg,top,bottom,border), getFontHeight(), getTextWidth(text), getRotation(), setRotation(deg), color(r,g,b)
input getTouch() -> x,y or nil, getRawTouch() -> raw ADC x,y or nil, isTouched()
fs readFile(path), writeFile(path, data), exists(path), listFiles(path), listDirs(path)
sys getMillis(), delay(ms), back(), canGoBack(), launch(path, arg), replace(path, arg), getAppName(), setAppName(name), getMemory() -> free,total,largest, isClockSynced(), setTickInterval(ms)
settings getRotation(), setRotation(deg), getTimezone(), setTimezone(tz), getTheme(), setTheme(name) (stores only; apps call ui.setTheme), setCalibration(x0,y0,x1,y1)
wifi scan() -> {ssid,rssi,secure}[], connect(ssid,password), getStatus() -> {state,ssid,ip,rssi}, getLocalIP(), isConnected(), disconnect(), forget()
log debug(msg), info(msg), error(msg) (serial)

Accessors are getName / setName / isName; bare names are actions (gui.fillRect) or pure conversions (gui.color, http.urlencode). settings.* is persisted user intent; gui.getRotation() is the frame actually being drawn, which differs while an app rotates the panel transiently.

Colors are RGB565 integers; build them with gui.color(r, g, b).

Settings

/settings.lua on the SD card is a Lua table literal, read at boot and rewritten by the settings app. It is Lua rather than JSON because the firmware already links Lua, so it needs no parser on either side:

return {
  rotation = 90,
  theme = "dark",
  touch = { x0 = 188, y0 = 232, x1 = 3799, y1 = 3800 },
  wifi = { ssid = "network", password = "secret" },
}

Missing file means the built-in defaults are used. apps/Settings walks two crosshairs and saves the result via settings.setCalibration(), cycles rotation through 0, 90, 180 and 270 degrees, and scans/selects Wi-Fi networks with an on-screen password keyboard. A saved network reconnects at boot.

Wi-Fi credentials are Lua-escaped but stored as plaintext on the SD card. Treat the card like any other device containing a saved password.

Rotation never needs a recalibration: calibration is stored in the panel's rotation-0 frame (320x480 raw ADC space) and the current rotation is applied afterwards, so settings.setRotation() is safe at any time. gui.setRotation(degrees) changes only the current frame; the firmware restores the saved rotation when an app exits. The glass itself is always portrait, so a rotated UI is drawn sideways on it.

make test            # everything below, non-zero on the first failure
Test Covers
test/round_rect_test.cpp corner geometry, coverage and RGB565 blending
test/ui_layout.lua layout rects, hit testing, press capture
test/ui_theme.lua palette derivation and inheritance
test/settings_calibration.lua calibration maths, menu and wifi flows

The Lua tests run against test/fake_device.lua, the one place the binding surface is stubbed, and make test refuses to run on anything but Lua 5.4 — the version the firmware vendors, so the tests cannot pass on a dialect the device will not run.

Drawing rounded surfaces

gui.roundRect(x, y, w, h, radius, bg, top, bottom, border) draws a whole surface in one pass. Fill and border come from the same signed distance field, so they cannot disagree at the corners, and edge pixels are anti-aliased by coverage. top/bottom are gradient stops (pass one for a solid, or nil for no fill) and border may be nil. Since the panel has no alpha channel, bg is the colour underneath that edge pixels blend into — pass the surface the shape sits on, not the shape's own fill.

The geometry lives in src/gfx/round_rect.h, free of Arduino headers, because the previous hand-rolled corner arc was wrong in a way only a pixel test would catch.

UI toolkit (/lib/ui.lua)

Apps describe nesting and sizes fall out, borrowing CSS block flow and the box model without the cascade:

local ui = require("ui")

local M = {}

function M.node()
  return ui.box{pad = 12, gap = 8,
    ui.text("settings"),
    ui.button{label = "calibrate touch", on_click = calibrate},
  }
end

return M

main.lua mounts that with ui.mount(), so nothing here dispatches touches or paints frames. A screen changes by changing state and calling ui.rebuild(), which runs node() again; building a node outside a rebuild is refused, because it would reset the arena under the screen already on the panel.

Sizes are pixels (>= 1), a fraction of the parent's content box (< 1), "fill" for all of it, or "auto" (the default on the flow axis; the cross axis fills the parent). Boxes take pad, gap, row, align, justify, border, capture, and at for absolute placement. color, bg, radius, gradient and the press palette inherit from the root, so a theme is set once.

Dialogs are not a layer the toolkit manages. A dialog is a node the app includes when its state calls for one, placed absolutely so it covers the flow rather than joining it, and dismissed by rebuilding without it:

function M.node()
  local content = ui.box{pad = 12, gap = 8, rows()}
  if not confirming then return ui.box{content} end
  return ui.box{content, ui.confirm{
    title = "forget network?", ok = "forget",
    on_ok = function() wifi.forget(); confirming = false; ui.rebuild() end,
    on_cancel = function() confirming = false; ui.rebuild() end,
  }}
end

Nothing has to survive a rebuild, because the tree is derived from state rather than mutated alongside it. capture = true makes a component swallow the taps its children missed, which is what stops a dialog being tapped through; it deliberately has no on_press, so a stray touch on a resistive panel answers nothing.

dimmed = true on a node darkens it and everything it contains, and ui.confirm opts its card back out, so a dialog sits lit over dimmed content. There is no alpha and no framebuffer to blend against: the whole palette is re-derived from seeds mixed toward black, and the content behind is simply repainted in those colors. Two color roles keep this honest — bg is the fill a node paints, surface is what sits underneath it, and anti-aliased edges blend into surface. A rounded card that blends into its own bg leaves square corners, which only shows once something behind it is a different color.

The toolkit owns press capture (release outside cancels), an 80 ms minimum pressed duration, touch slop, and per-component dirty tracking. A custom painter can add paint_part(node, id) and call node:invalidatePart(id) to repaint only a self-contained region; a normal invalidate() still supersedes queued regions. For partial pressed feedback, on_down returns the region ID and invalidate_press(node, id) invalidates it; the screen retains that ID through the 80 ms hold. Any table with measure, place, draw and hit drops into the tree, so custom components need no buy-in.

Not implemented: scrolling, so a list longer than the screen is unreachable.

/lib/keyboard.lua provides a single-node, staggered QWERTY keyboard so its keys do not retain dozens of component tables. It owns shift, number/symbol pages, backspace, per-key pressed feedback, and release-over-the-same-key activation:

keyboard.new{
  value = password,
  max_length = 64,
  on_change = function(value) password = value end,
  on_submit = connect,
}

Themes (/lib/theme.lua)

A theme is three seed colors; ui.lua derives the rest, so adding a component never means editing a theme, and a theme cannot state its own contrast wrongly:

return {
  light = {bg = {255, 255, 255}, fg = {0, 0, 0}, accent = {0, 120, 255}},
  midnight = {bg = {12, 14, 30}, fg = {220, 225, 240}, accent = {255, 120, 0}},
}
Role Derivation
bg, fg, accent the seeds
muted fg blended 45% toward bg, for secondary text
accent_fg black or white, whichever the accent's luminance demands
face, face_pressed button gradients, from bg and accent
radius 6

Any derived role can be pinned in the theme file (radius = 0, muted = {120,120,120}). The settings app cycles through the names it finds, and stores only the name.

Components get the theme by inheritance, so a themed app names no colors at all. The division is: inheritance carries context (self.color, self.bg — what surface am I on), while ui.theme carries constants (ui.theme.accent). Read the theme directly only when drawing outside the component tree, like the calibration crosshair. An explicit value on any node still wins, which is how a component deviates:

ui.button{label = "delete", press_bg = DANGER}

The C++ Lua-error and SD-failure screens stay hardcoded high contrast, since they can fire when the settings that name the theme are themselves unreadable.

Pin map (fixed by the board)

  • Display (VSPI): SCK 14, MOSI 13, MISO 12, CS 15, DC 2, backlight 27, reset = EN
  • Touch: shares display SPI, CS 33, IRQ 36
  • SD: SCK 18, MOSI 23, MISO 19, CS 5

TFT_eSPI, XPT2046 and SD each grab the SPI controller but share the same wires; only one transfers at a time, which is safe here (see ponytail note in main.cpp).

S
Description
No description provided
Readme 1.1 MiB
Languages
C++ 51%
Lua 45.5%
Makefile 1.4%
C 1.4%
Nix 0.7%