feat(ui): layout toolkit, Lua launcher and touch press events

Apps describe nesting instead of coordinates: /lib/ui.lua borrows CSS block flow,
the box model and auto sizing, and owns hit testing, press capture and the pressed
repaint. The runtime gains require backed by the SD card, on_touch_down/on_touch_up,
text metrics and rounded gradient fills, so the launcher becomes an ordinary Lua app
and the firmware keeps only a fallback screen for an unreadable card.
This commit is contained in:
2026-07-31 22:27:34 -04:00
parent ad396d3f01
commit 4e5796fd51
10 changed files with 789 additions and 148 deletions
+16 -2
View File
@@ -55,7 +55,14 @@ printf '%s\n' 'boot .pio/build/esp32-32e/firmware.bin' 'wait-log "launcher ready
## Synchronization
Firmware sync points, in order: `launcher ready` (menu drawn, touch accepted), `launching /apps/<name>/main.lua` (entry selected), then whatever the app logs through `log.info(...)`, which appears as `[lua] ...`. Returning from an app logs `launcher ready` again.
Firmware sync points, in order: `launching /apps/<name>/main.lua` (printed by the
runtime for every app, including the launcher itself), `[lua] launcher ready` (menu
drawn, touch accepted), then whatever the app logs through `log.info(...)`. Exiting an
app relaunches the launcher, so both lines repeat.
A scripted tap must land after the app is idle: `setup()` can block for seconds on a
full-screen clear under TCG, and a 250 ms press that lands during it is missed
entirely. Wait for the app's own ready log, then `sleep` a few seconds.
- Use `wait-log REGEX` as the default synchronization and assertion mechanism.
- Assert on the `launching ...` line rather than on tap coordinates: menu rows follow SD directory order, which the image build decides.
@@ -65,7 +72,14 @@ Firmware sync points, in order: `launcher ready` (menu drawn, touch accepted), `
## Touch
`tap` always takes physical panel pixels (320x480 portrait), because the glass never rotates. Launcher rows start at y=40 and are 36px apart, so entry `i` is at `y = 40 + 36 * i + 14`; the first row centre is `tap 60 55`.
`tap` always takes physical panel pixels (320x480 portrait), because the glass never rotates.
Screens are laid out by `/lib/ui.lua`, so read tap targets off a `capture` rather than
computing them. For the default launcher and settings styling (`pad = 12`, `gap = 8`, a
title 8px tall, buttons 24px tall) rows land at y=28, 60 and 92.
Use `touch-hold` plus `capture` plus `touch-release` to photograph a pressed button:
`on_press` fires on release, and the pressed style is only visible mid-gesture.
When the firmware runs rotated, convert the UI position `(sx, sy)` to the physical tap:
+42 -7
View File
@@ -13,19 +13,24 @@ pio run -t upload # flash over USB-C
pio device monitor # serial logs
```
Copy `sdcard/apps/` to the SD card root (`/apps/<name>/main.lua`).
Copy `sdcard/` to the SD card root: apps live in `/apps/<name>/main.lua` and shared
Lua modules in `/lib`. The launcher is itself an app (`/apps/launcher/main.lua`); the
firmware only draws a fallback screen if it cannot start.
## Lua API
Apps define optional callbacks: `setup()`, `draw()` (~30fps cap), `on_touch(x, y)`,
`on_tick()` (enabled by setting `TICK_MS`). Call `sys.exit()` to return to the launcher.
Apps define optional callbacks: `setup()`, `draw()` (~30fps cap), `on_touch_down(x, y)`,
`on_touch_up(x, y)`, `on_touch(x, y)` (tap alias, fired on release), and `on_tick()`
(enabled by setting `TICK_MS`). Call `sys.exit()` to return to the launcher.
`require` reads from the SD card: `/apps/<name>/?.lua` first, then `/lib/?.lua`.
| Module | Functions |
|---|---|
| `gui` | `width()`, `height()`, `clear(color)`, `fillRect(x,y,w,h,c)`, `drawRect(x,y,w,h,c)`, `fillCircle(x,y,r,c)`, `drawLine(x1,y1,x2,y2,c)`, `drawText(text,x,y,fg,bg)`, `setRotation(0-3)`, `color(r,g,b)` |
| `gui` | `width()`, `height()`, `clear(color)`, `fillRect(x,y,w,h,c)`, `drawRect(x,y,w,h,c)`, `fillCircle(x,y,r,c)`, `drawLine(x1,y1,x2,y2,c)`, `drawText(text,x,y,fg,bg)`, `fillRoundRect(x,y,w,h,r,c)`, `drawRoundRect(x,y,w,h,r,c)`, `fillRectGradient(x,y,w,h,r,top,bottom)`, `fontHeight()`, `textWidth(text)`, `setRotation(0-3)`, `color(r,g,b)` |
| `input` | `getTouch()` -> `x,y` or nil, `getRawTouch()` -> raw ADC `x,y` or nil, `touched()` |
| `fs` | `readFile(path)`, `writeFile(path, data)`, `exists(path)`, `listFiles(path)` |
| `sys` | `millis()`, `exit()`, `setCalibration(x0,y0,x1,y1)`, `getRotation()`, `setRotation(deg)` |
| `fs` | `readFile(path)`, `writeFile(path, data)`, `exists(path)`, `listFiles(path)`, `listDirs(path)` |
| `sys` | `millis()`, `exit()`, `launch(path)`, `setCalibration(x0,y0,x1,y1)`, `getRotation()`, `setRotation(deg)` |
| `log` | `info(msg)` (serial) |
Colors are RGB565 integers; build them with `gui.color(r, g, b)`.
@@ -55,9 +60,39 @@ app exits. The glass itself is always portrait, so a rotated UI is drawn
sideways on it.
```sh
nix run nixpkgs#lua -- test/settings_calibration.lua # calibration math check
nix run nixpkgs#lua -- test/ui_layout.lua # layout, hit testing, capture
nix run nixpkgs#lua -- test/settings_calibration.lua # calibration math and menu flow
```
## UI toolkit (`/lib/ui.lua`)
Apps describe nesting and sizes fall out, borrowing CSS block flow and the box model
without the cascade:
```lua
local ui = require("ui")
local screen = ui.screen(ui.box{pad = 12, gap = 8, color = BLACK, bg = WHITE,
ui.text("settings"),
ui.button{label = "calibrate touch", on_press = calibrate},
})
function draw() screen:draw() end
function on_touch_down(x, y) screen:down(x, y) end
function on_touch_up(x, y) screen:up(x, y) end
```
Sizes are pixels (>= 1), a fraction of the parent's content box (< 1), or `"auto"`
(the default on the flow axis; the cross axis fills the parent). Boxes take `pad`,
`gap`, `row`, `align` and `at` for absolute placement. `color`, `bg`, `radius`,
`gradient` and the press palette inherit from the root, so a theme is set once.
The toolkit owns press capture (release outside cancels), an 80 ms minimum pressed
duration, touch slop, and per-component dirty tracking. 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.
## Pin map (fixed by the board)
- Display (VSPI): SCK 14, MOSI 13, MISO 12, CS 15, DC 2, backlight 27, reset = EN
+49
View File
@@ -0,0 +1,49 @@
local ui = require("ui")
local BLACK, WHITE = gui.color(0, 0, 0), gui.color(255, 255, 255)
local ACCENT, MUTED = gui.color(0, 120, 255), gui.color(120, 120, 120)
local FACE = {gui.color(255, 255, 255), gui.color(222, 228, 236)}
local FACE_PRESSED = {gui.color(0, 140, 255), gui.color(0, 96, 220)}
local screen
-- Rows are interactive containers rather than plain buttons, so each can carry a
-- name plus secondary text (and an icon later) while still being one tap target.
local function appRow(name)
return ui.button{
pad = {t = 8, r = 10, b = 8, l = 10},
align = "start",
on_press = function() sys.launch("/apps/" .. name .. "/main.lua") end,
ui.text(name),
}
end
function setup()
local items = {
pad = 12,
gap = 8,
color = BLACK,
bg = WHITE,
press_bg = ACCENT,
press_color = WHITE,
gradient = FACE,
press_gradient = FACE_PRESSED,
ui.text("esp32-lcd"),
}
local names = fs.listDirs("/apps")
table.sort(names)
for _, name in ipairs(names) do
if name ~= "launcher" then items[#items + 1] = appRow(name) end
end
if #names <= 1 then
items[#items + 1] = ui.text("no apps in /apps", {color = MUTED})
end
screen = ui.screen(ui.box(items))
log.info("launcher ready")
end
function draw() screen:draw() end
function on_touch_down(x, y) screen:down(x, y) end
function on_touch_up(x, y) screen:up(x, y) end
+56 -52
View File
@@ -1,30 +1,24 @@
local ui = require("ui")
TICK_MS = 50
local INSET = 30
local BLACK, WHITE, RED = gui.color(0, 0, 0), gui.color(255, 255, 255), gui.color(255, 0, 0)
local ROWS = {"calibrate touch", "rotation", "exit"}
local ACCENT = gui.color(0, 120, 255)
local screen, message
local mode = "menu"
local samples = {}
local pending = nil
local armed = false
local message = nil
local samples, pending, armed = {}, nil, false
local function rowY(index)
return 40 * index
end
local buildMenu, startCalibration, cycleRotation
local function drawMenu()
gui.clear(WHITE)
gui.drawText("settings", 10, 10, BLACK)
for i, label in ipairs(ROWS) do
if label == "rotation" then
label = "rotation: " .. sys.getRotation() .. " deg"
end
gui.drawRect(10, rowY(i), gui.width() - 20, 28, BLACK)
gui.drawText(label, 18, rowY(i) + 6, BLACK)
end
if message then gui.drawText(message, 10, rowY(#ROWS + 1) + 10, BLACK) end
-- Two inset targets give a raw-per-pixel slope; extrapolate it to the screen edges.
-- Exposed as a global so test/settings_calibration.lua can exercise it.
function computeCalibration(s1, s2, w, h, inset)
local sx = (s2.x - s1.x) / (w - 2 * inset)
local sy = (s2.y - s1.y) / (h - 2 * inset)
return math.floor(s1.x - sx * inset), math.floor(s1.y - sy * inset),
math.floor(s2.x + sx * inset), math.floor(s2.y + sy * inset)
end
local function target(n)
@@ -36,52 +30,62 @@ end
local function drawTarget(n)
local x, y = target(n)
gui.clear(WHITE)
gui.drawText("tap the cross", 10, 10, BLACK)
gui.drawText("tap the cross", 10, 10, BLACK, WHITE)
gui.drawLine(x - 12, y, x + 12, y, RED)
gui.drawLine(x, y - 12, x, y + 12, RED)
end
-- Two inset targets give a raw-per-pixel slope; extrapolate it to the screen edges.
-- Exposed as a global so test/settings_calibration.lua can exercise it.
function computeCalibration(s1, s2, w, h, inset)
local sx = (s2.x - s1.x) / (w - 2 * inset)
local sy = (s2.y - s1.y) / (h - 2 * inset)
return math.floor(s1.x - sx * inset), math.floor(s1.y - sy * inset),
math.floor(s2.x + sx * inset), math.floor(s2.y + sy * inset)
end
local function finish()
local function finishCalibration()
local ok = sys.setCalibration(computeCalibration(samples[1], samples[2], 320, 480, INSET))
message = ok and "calibration saved" or "save failed"
mode = "menu"
gui.setRotation(sys.getRotation() / 90)
drawMenu()
buildMenu()
end
function startCalibration()
message = nil
mode = "calibrate"
samples, pending, armed = {}, nil, false
gui.setRotation(0)
drawTarget(1)
end
function cycleRotation()
local ok = sys.setRotation((sys.getRotation() + 90) % 360)
message = ok and "rotation saved" or "save failed"
buildMenu() -- the frame changed size, so lay out again
end
function buildMenu()
local items = {
pad = 12,
gap = 8,
ui.text("settings"),
ui.button{label = "calibrate touch", on_press = startCalibration},
ui.button{label = "rotation: " .. sys.getRotation() .. " deg", on_press = cycleRotation},
ui.button{label = "exit", on_press = sys.exit},
}
if message then items[#items + 1] = ui.text(message) end
items.color, items.bg = BLACK, WHITE
items.press_bg, items.press_color = ACCENT, WHITE
screen = ui.screen(ui.box(items))
end
function setup()
drawMenu()
buildMenu()
end
function on_touch(x, y)
if mode ~= "menu" then return end
local index = math.floor(y / 40)
if y < rowY(1) or index > #ROWS then return end
function draw()
if mode == "menu" then screen:draw() end
end
if ROWS[index] == "calibrate touch" then
message = nil
mode = "calibrate"
samples = {}
pending = nil
armed = false -- the menu tap is still down; wait for release
gui.setRotation(0)
drawTarget(1)
elseif ROWS[index] == "rotation" then
local ok = sys.setRotation((sys.getRotation() + 90) % 360)
message = ok and "rotation saved" or "save failed"
drawMenu()
elseif ROWS[index] == "exit" then
sys.exit()
end
function on_touch_down(x, y)
if mode == "menu" then screen:down(x, y) end
end
function on_touch_up(x, y)
if mode == "menu" then screen:up(x, y) end
end
function on_tick()
@@ -99,7 +103,7 @@ function on_tick()
if #samples == 1 then
drawTarget(2)
else
finish()
finishCalibration()
end
end
end
+252
View File
@@ -0,0 +1,252 @@
-- Layout borrowed from CSS block flow: nesting plus box model, no cascade.
-- Sizes are pixels (>= 1), a fraction of the parent content box (< 1), or "auto".
local ui = {}
local PRESS_MS = 80 -- a fast tap must stay visible for at least this long
local SLOP = 4 -- resistive panels land a few pixels off
local function resolve(value, span, axis)
if value == nil or value == "auto" then return nil end
if value < 1 then
if span == nil then
error("fractional " .. axis .. " inside an auto-sized parent", 3)
end
return math.floor(value * span)
end
return math.floor(value)
end
local function padding(spec)
local p = spec.pad or 0
if type(p) == "number" then return {t = p, r = p, b = p, l = p} end
return {t = p.t or 0, r = p.r or 0, b = p.b or 0, l = p.l or 0}
end
-- The palette set on the root reaches every descendant, so styling is one place.
local INHERITED = {"color", "bg", "size", "button_bg", "press_bg", "press_color", "radius",
"gradient", "press_gradient"}
local function inherit(child, parent)
for _, key in ipairs(INHERITED) do
if child[key] == nil then child[key] = parent[key] end
end
end
local function contains(rect, x, y)
return x >= rect.x - SLOP and x < rect.x + rect.w + SLOP
and y >= rect.y - SLOP and y < rect.y + rect.h + SLOP
end
-- Components ---------------------------------------------------------------
local Component = {}
Component.__index = Component
function Component:measure(available)
local pad = self.pad_
local w = resolve(self.w, available.w, "width")
local h = resolve(self.h, available.h, "height")
local inner = {
w = w and w - pad.l - pad.r or (available.w and available.w - pad.l - pad.r),
h = h and h - pad.t - pad.b or nil,
}
local main, cross = 0, 0
for index, child in ipairs(self.children) do
inherit(child, self)
local cw, ch = child:measure(inner)
if self.row then
main = main + cw + (index > 1 and self.gap or 0)
cross = math.max(cross, ch)
else
main = main + ch + (index > 1 and self.gap or 0)
cross = math.max(cross, cw)
end
end
if self.row then
self.mw = w or main + pad.l + pad.r
self.mh = h or cross + pad.t + pad.b
else
self.mw = w or cross + pad.l + pad.r
self.mh = h or main + pad.t + pad.b
end
return self.mw, self.mh
end
function Component:place(rect)
self.rect = rect
self.dirty = true
local pad = self.pad_
local content = {
x = rect.x + pad.l,
y = rect.y + pad.t,
w = rect.w - pad.l - pad.r,
h = rect.h - pad.t - pad.b,
}
local offset = 0
for _, child in ipairs(self.children) do
local cw, ch = child.mw, child.mh
-- Cross axis fills the parent unless the child asked for a size, like CSS blocks.
if self.row then
if child.h == nil or child.h == "auto" then ch = content.h end
else
if child.w == nil or child.w == "auto" then cw = content.w end
end
local x, y
if child.at then
x = content.x + resolve(child.at.x, content.w, "x")
y = content.y + resolve(child.at.y, content.h, "y")
elseif self.row then
x, y = content.x + offset, content.y
if self.align == "center" then y = y + math.floor((content.h - ch) / 2)
elseif self.align == "end" then y = y + content.h - ch end
offset = offset + cw + self.gap
else
x, y = content.x, content.y + offset
if self.align == "center" then x = x + math.floor((content.w - cw) / 2)
elseif self.align == "end" then x = x + content.w - cw end
offset = offset + ch + self.gap
end
child:place{x = x, y = y, w = cw, h = ch}
end
end
function Component:draw()
if self.dirty then
if self.bg then gui.fillRect(self.rect.x, self.rect.y, self.rect.w, self.rect.h, self.bg) end
if self.paint then self:paint() end
self.dirty = false
for _, child in ipairs(self.children) do child.dirty = true end
end
for _, child in ipairs(self.children) do child:draw() end
end
-- Deepest interactive component wins, so a tappable child beats its tappable parent.
function Component:hit(x, y)
if not self.rect or not contains(self.rect, x, y) then return nil end
for index = #self.children, 1, -1 do
local found = self.children[index]:hit(x, y)
if found then return found end
end
return self.on_press and self or nil
end
function Component:invalidate()
self.dirty = true
end
local function component(spec)
spec.children = spec.children or {}
for index, child in ipairs(spec) do
spec.children[index] = child
spec[index] = nil
end
spec.gap = spec.gap or 0
spec.align = spec.align or "start"
spec.pad_ = padding(spec)
return setmetatable(spec, Component)
end
ui.box = component
function ui.spacer(spec)
return component{w = spec.w, h = spec.h}
end
function ui.text(label, spec)
spec = spec or {}
spec.label = label
local node = component(spec)
node.measure = function(self, available)
self.mw = resolve(self.w, available.w, "width") or gui.textWidth(self.label)
self.mh = resolve(self.h, available.h, "height") or gui.fontHeight()
return self.mw, self.mh
end
node.paint = function(self)
gui.drawText(self.label, self.rect.x, self.rect.y, self.color, self.bg)
end
return node
end
function ui.button(spec)
spec.pad = spec.pad or 8
spec.align = spec.align or "center"
if spec.label then
spec.children = {ui.text(spec.label)}
spec.label = nil
end
local node = component(spec)
node.paint = function(self)
local r = self.rect
local radius = self.radius or 6
local fill = self.pressed and (self.press_bg or self.color) or (self.button_bg or self.bg)
local gradient = self.pressed and self.press_gradient or (not self.pressed and self.gradient)
if gradient then
gui.fillRectGradient(r.x, r.y, r.w, r.h, radius, gradient[1], gradient[2])
fill = gradient[2] -- text blends against the bottom stop
elseif fill then
gui.fillRoundRect(r.x, r.y, r.w, r.h, radius, fill)
end
gui.drawRoundRect(r.x, r.y, r.w, r.h, radius, self.color)
for _, child in ipairs(self.children) do
child.bg = fill
child.color = self.pressed and (self.press_color or self.bg) or self.color
child.dirty = true
end
end
return node
end
-- Screen -------------------------------------------------------------------
local Screen = {}
Screen.__index = Screen
function ui.screen(root, style)
local screen = setmetatable({root = root, captured = nil, pressedAt = 0}, Screen)
root.color = root.color or (style and style.color) or gui.color(0, 0, 0)
root.bg = root.bg or (style and style.bg) or gui.color(255, 255, 255)
screen:relayout()
return screen
end
function Screen:relayout()
local rect = {x = 0, y = 0, w = gui.width(), h = gui.height()}
self.root:measure(rect)
self.root:place(rect)
gui.clear(self.root.bg)
end
function Screen:draw()
self.root:draw()
-- Hold the pressed look briefly so a fast tap is still perceptible.
if self.released and sys.millis() - self.pressedAt >= PRESS_MS then
self.released.pressed = false
self.released:invalidate()
self.released = nil
end
end
function Screen:down(x, y)
local target = self.root:hit(x, y)
if not target then return end
self.captured = target
self.pressedAt = sys.millis()
target.pressed = true
target:invalidate()
end
function Screen:up(x, y)
local target = self.captured
self.captured = nil
if not target then return end
self.released = target
local inside = contains(target.rect, x, y)
if inside and target.on_press then target.on_press(target) end
end
return ui
+219 -19
View File
@@ -67,6 +67,53 @@ static int l_gui_drawLine(lua_State* L) {
return 0;
}
static int l_gui_fillRoundRect(lua_State* L) {
app(L)->tft.fillRoundRect(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3),
luaL_checkinteger(L, 4), luaL_checkinteger(L, 5), luaL_checkinteger(L, 6));
return 0;
}
static int l_gui_drawRoundRect(lua_State* L) {
app(L)->tft.drawRoundRect(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3),
luaL_checkinteger(L, 4), luaL_checkinteger(L, 5), luaL_checkinteger(L, 6));
return 0;
}
// Interpolating in RGB565 keeps this a pure integer loop; a rounded corner just
// insets the span, so gradient buttons need no separate corner fill.
static int l_gui_fillRectGradient(lua_State* L) {
int x = luaL_checkinteger(L, 1), y = luaL_checkinteger(L, 2);
int w = luaL_checkinteger(L, 3), h = luaL_checkinteger(L, 4);
int radius = luaL_checkinteger(L, 5);
uint16_t top = luaL_checkinteger(L, 6), bottom = luaL_checkinteger(L, 7);
if (w <= 0 || h <= 0) return 0;
int r1 = top >> 11, g1 = (top >> 5) & 0x3F, b1 = top & 0x1F;
int r2 = bottom >> 11, g2 = (bottom >> 5) & 0x3F, b2 = bottom & 0x1F;
radius = constrain(radius, 0, min(w, h) / 2);
for (int row = 0; row < h; row++) {
uint16_t color = ((r1 + (r2 - r1) * row / (h - 1)) << 11) |
((g1 + (g2 - g1) * row / (h - 1)) << 5) |
(b1 + (b2 - b1) * row / (h - 1));
int inset = 0;
int edge = row < radius ? radius - row : (row >= h - radius ? row - (h - radius - 1) : 0);
if (edge > 0) inset = radius - (int)sqrtf((float)(radius * radius - edge * edge));
app(L)->tft.drawFastHLine(x + inset, y + row, w - 2 * inset, color);
}
return 0;
}
static int l_gui_fontHeight(lua_State* L) {
lua_pushinteger(L, app(L)->tft.fontHeight());
return 1;
}
static int l_gui_textWidth(lua_State* L) {
lua_pushinteger(L, app(L)->tft.textWidth(luaL_checkstring(L, 1)));
return 1;
}
static int l_gui_drawText(lua_State* L) {
const char* text = luaL_checkstring(L, 1);
int x = luaL_checkinteger(L, 2);
@@ -145,6 +192,12 @@ static int l_input_getRawTouch(lua_State* L) {
return 2;
}
static int l_sys_launch(lua_State* L) {
app(L)->requestLaunch(luaL_checkstring(L, 1));
app(L)->requestExit();
return 0;
}
static int l_sys_getRotation(lua_State* L) {
lua_pushinteger(L, settings.rotation);
return 1;
@@ -210,6 +263,25 @@ static int l_fs_exists(lua_State* L) {
return 1;
}
static int l_fs_listDirs(lua_State* L) {
File dir = SD.open(luaL_checkstring(L, 1));
lua_newtable(L);
if (!dir || !dir.isDirectory()) {
if (dir) dir.close();
return 1;
}
int i = 1;
for (File entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
if (entry.isDirectory() && entry.name()[0] != '.') {
lua_pushstring(L, entry.name());
lua_rawseti(L, -2, i++);
}
entry.close();
}
dir.close();
return 1;
}
static int l_fs_listFiles(lua_State* L) {
File dir = SD.open(luaL_checkstring(L, 1));
lua_newtable(L);
@@ -236,6 +308,108 @@ static int l_log(lua_State* L) {
return 0;
}
// ---- module loading ----
// Lua's stock loaders go through stdio, which cannot see the SD mount, so every
// path into the filesystem is replaced with one that reads through SD.
static bool readScript(const char* path, String& out) {
// Probe first: a missed SD.open logs a VFS error, and the searcher misses by design.
if (!SD.exists(path)) return false;
File f = SD.open(path);
if (!f || f.isDirectory()) {
if (f) f.close();
return false;
}
out = f.readString();
f.close();
return true;
}
static int loadScript(lua_State* L, const char* path) {
String source;
if (!readScript(path, source)) {
lua_pushfstring(L, "cannot open %s", path);
return LUA_ERRFILE;
}
String chunkname = String("@") + path;
return luaL_loadbuffer(L, source.c_str(), source.length(), chunkname.c_str());
}
static int l_loadfile(lua_State* L) {
if (loadScript(L, luaL_checkstring(L, 1)) == LUA_OK) return 1;
lua_pushnil(L);
lua_insert(L, -2);
return 2;
}
static int l_dofile(lua_State* L) {
const char* path = luaL_checkstring(L, 1);
if (loadScript(L, path) != LUA_OK) return lua_error(L);
lua_call(L, 0, LUA_MULTRET);
return lua_gettop(L) - 1;
}
// Resolves a module name against package.path, reporting every path tried the way
// the stock searcher does.
static int l_searcher(lua_State* L) {
String name = luaL_checkstring(L, 1);
name.replace('.', '/');
lua_getglobal(L, "package");
lua_getfield(L, -1, "path");
String templates = luaL_optstring(L, -1, "");
lua_pop(L, 2);
String tried;
int start = 0;
while (start <= (int)templates.length()) {
int end = templates.indexOf(';', start);
if (end < 0) end = templates.length();
String candidate = templates.substring(start, end);
start = end + 1;
if (candidate.length() == 0) continue;
candidate.replace("?", name);
String source;
if (readScript(candidate.c_str(), source)) {
String chunkname = String("@") + candidate;
if (luaL_loadbuffer(L, source.c_str(), source.length(), chunkname.c_str()) != LUA_OK) {
return luaL_error(L, "error loading module '%s' from '%s':\n\t%s",
luaL_checkstring(L, 1), candidate.c_str(), lua_tostring(L, -1));
}
lua_pushstring(L, candidate.c_str());
return 2;
}
tried += "\n\tno file '" + candidate + "'";
}
lua_pushstring(L, tried.c_str());
return 1;
}
void LuaApp::installLoader(const char* appDir) {
lua_getglobal(state, "package");
String path = String(appDir) + "/?.lua;/lib/?.lua";
lua_pushstring(state, path.c_str());
lua_setfield(state, -2, "path");
// Keep the preload searcher, drop the C loaders: they can only report misleading
// errors about shared objects that never existed here.
lua_getfield(state, -1, "searchers");
lua_pushcfunction(state, l_searcher);
lua_rawseti(state, -2, 2);
for (int i = 3; i <= 4; i++) {
lua_pushnil(state);
lua_rawseti(state, -2, i);
}
lua_pop(state, 2);
lua_pushcfunction(state, l_loadfile);
lua_setglobal(state, "loadfile");
lua_pushcfunction(state, l_dofile);
lua_setglobal(state, "dofile");
}
LuaApp::LuaApp(TFT_eSPI& tft, XPT2046_Touchscreen& touch) : tft(tft), touch(touch) {}
LuaApp::~LuaApp() {
@@ -257,6 +431,14 @@ bool LuaApp::callGlobal(const char* name, int nargs) {
return true;
}
void LuaApp::fireTouch(const char* name, int16_t x, int16_t y) {
if (!hasGlobal(name)) return;
lua_getglobal(state, name);
lua_pushinteger(state, x);
lua_pushinteger(state, y);
callGlobal(name, 2);
}
void LuaApp::fail(const char* message) {
Serial.printf("[lua] error: %s\n", message ? message : "(unknown)");
tft.fillScreen(TFT_WHITE);
@@ -269,21 +451,19 @@ void LuaApp::fail(const char* message) {
bool LuaApp::load(const char* path) {
self = this;
closeState();
// The launcher tap may still be down; require a release before the first edge.
// The launcher tap may still be down; swallow that gesture's release.
lastTouched = true;
ignoreRelease = true;
state = luaL_newstate();
if (!state) return false;
luaL_openlibs(state);
registerBindings();
File f = SD.open(path);
if (!f) {
fail("cannot open script");
return false;
}
String source = f.readString();
f.close();
if (luaL_loadbuffer(state, source.c_str(), source.length(), path) != LUA_OK) {
String appDir = path;
int slash = appDir.lastIndexOf('/');
installLoader(slash > 0 ? appDir.substring(0, slash).c_str() : "/");
if (loadScript(state, path) != LUA_OK) {
fail(lua_tostring(state, -1));
return false;
}
@@ -306,19 +486,26 @@ void LuaApp::registerBindings() {
{"millis", l_sys_millis}, {"exit", l_sys_exit},
{"setCalibration", l_sys_setCalibration},
{"getRotation", l_sys_getRotation}, {"setRotation", l_sys_setRotation},
{"launch", l_sys_launch},
{nullptr, nullptr}};
static const luaL_Reg guiLib[] = {
{"width", l_gui_width}, {"height", l_gui_height}, {"clear", l_gui_clear},
{"fillRect", l_gui_fillRect}, {"drawRect", l_gui_drawRect}, {"fillCircle", l_gui_fillCircle},
{"drawLine", l_gui_drawLine}, {"drawText", l_gui_drawText}, {"setRotation", l_gui_setRotation},
{"color", l_gui_color}, {nullptr, nullptr}};
{"color", l_gui_color},
{"fillRoundRect", l_gui_fillRoundRect},
{"drawRoundRect", l_gui_drawRoundRect},
{"fillRectGradient", l_gui_fillRectGradient},
{"fontHeight", l_gui_fontHeight},
{"textWidth", l_gui_textWidth},
{nullptr, nullptr}};
static const luaL_Reg inputLib[] = {{"getTouch", l_input_getTouch},
{"getRawTouch", l_input_getRawTouch},
{"touched", l_input_touched},
{nullptr, nullptr}};
static const luaL_Reg fsLib[] = {{"readFile", l_fs_readFile}, {"writeFile", l_fs_writeFile},
{"exists", l_fs_exists}, {"listFiles", l_fs_listFiles},
{nullptr, nullptr}};
{"listDirs", l_fs_listDirs}, {nullptr, nullptr}};
static const luaL_Reg logLib[] = {{"info", l_log}, {nullptr, nullptr}};
luaL_newlib(state, sysLib);
@@ -337,6 +524,14 @@ void LuaApp::registerBindings() {
// here would free the VM that is still executing.
void LuaApp::requestExit() { exitRequested = true; }
void LuaApp::requestLaunch(const char* path) { pendingLaunch = path; }
String LuaApp::takePendingLaunch() {
String path = pendingLaunch;
pendingLaunch = "";
return path;
}
void LuaApp::closeState() {
if (state) {
lua_close(state);
@@ -351,14 +546,19 @@ void LuaApp::loop() {
uint32_t now = millis();
bool touched = touch.touched();
if (touched && !lastTouched && hasGlobal("on_touch")) {
lua_getglobal(state, "on_touch");
// The finger can lift between polls; calling with an empty stack corrupts it.
if (touchPoint(state)) {
callGlobal("on_touch", 2);
} else {
lua_pop(state, 1);
}
if (touched) {
TS_Point p = touch.getPoint();
mapTouch(tft, p, lastX, lastY);
}
if (touched && !lastTouched) {
fireTouch("on_touch_down", lastX, lastY);
if (!running()) return;
} else if (!touched && lastTouched && ignoreRelease) {
ignoreRelease = false; // the press that launched this app is not its own gesture
} else if (!touched && lastTouched) {
fireTouch("on_touch_up", lastX, lastY);
if (!running()) return;
fireTouch("on_touch", lastX, lastY); // tap alias, fired on release like a click
if (!running()) return;
}
lastTouched = touched;
+11
View File
@@ -17,6 +17,10 @@ class LuaApp {
bool load(const char* path);
void loop();
// Set by sys.launch(); the host loop reads it once the app has torn down.
void requestLaunch(const char* path);
String takePendingLaunch();
bool running() const { return state != nullptr && !exitRequested; }
void requestExit();
@@ -32,9 +36,16 @@ class LuaApp {
private:
lua_State* state = nullptr;
bool exitRequested = false;
String pendingLaunch;
bool lastTouched = false;
bool ignoreRelease = false;
// The controller reports no position once the finger lifts, so on_touch_up and the
// tap alias replay the last point seen while it was down.
int16_t lastX = 0, lastY = 0;
void closeState();
void installLoader(const char* appDir);
void fireTouch(const char* name, int16_t x, int16_t y);
uint32_t tickIntervalMs = 0;
uint32_t nextTickMs = 0;
uint32_t nextDrawMs = 0;
+26 -63
View File
@@ -13,56 +13,35 @@ static constexpr int SD_MOSI = 23;
static constexpr int SD_MISO = 19;
static constexpr int TOUCH_CS = 33;
static const char* LAUNCHER = "/apps/launcher/main.lua";
TFT_eSPI tft;
SPIClass touchSpi(HSPI);
SPIClass& sdSpi = SPI;
XPT2046_Touchscreen touch(TOUCH_CS);
LuaApp app(tft, touch);
bool sdOk = false;
String appPath;
bool halted = false;
String nextApp;
void drawLauncher() {
// Last resort only: the UI lives in Lua, so this exists purely to explain why
// nothing else could run.
void fallbackScreen(const char* message) {
tft.setRotation(0);
tft.fillScreen(TFT_WHITE);
tft.setTextColor(TFT_RED, TFT_WHITE);
tft.drawString(message, 10, 10);
tft.setTextColor(TFT_BLACK, TFT_WHITE);
tft.drawString("esp32-lcd - tap an app", 10, 10);
File dir = SD.open("/apps");
int y = 40;
while (File entry = dir.openNextFile()) {
if (entry.isDirectory() && entry.name()[0] != '.') {
tft.drawRect(10, y - 4, tft.width() - 20, 28, TFT_BLACK);
tft.drawString(entry.name(), 18, y);
y += 36;
}
entry.close();
}
dir.close();
tft.drawString("expected /apps/launcher/main.lua", 10, 30);
Serial.printf("halted: %s\n", message);
halted = true;
}
// ponytail: first N rows of 36px = first N app dirs, in SD order. A scrollable
// menu widget can come later; this is a launcher, not an OS.
void launcherTouch() {
if (!touch.touched()) return;
TS_Point p = touch.getPoint();
int16_t tx, ty;
LuaApp::mapTouch(tft, p, tx, ty);
int idx = (ty - 40) / 36;
if (idx < 0) return;
File dir = SD.open("/apps");
int i = 0;
while (File entry = dir.openNextFile()) {
if (entry.isDirectory() && entry.name()[0] != '.') {
if (i++ == idx) {
appPath = String("/apps/") + entry.name() + "/main.lua";
entry.close();
break;
}
}
entry.close();
}
dir.close();
void startApp(const String& path) {
tft.setRotation(settings.rotationIndex()); // apps may have rotated the frame
Serial.printf("launching %s\n", path.c_str());
if (app.load(path.c_str())) return;
if (path == LAUNCHER) fallbackScreen("launcher failed to start");
}
void setup() {
@@ -75,40 +54,24 @@ void setup() {
sdSpi.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
if (!SD.begin(SD_CS, sdSpi)) {
tft.setTextColor(TFT_RED, TFT_WHITE);
tft.drawString("SD card mount failed", 10, 10);
Serial.println("SD mount failed");
fallbackScreen("SD card mount failed");
return;
}
sdOk = true;
settings.load(); // absent file keeps the built-in touch defaults
tft.setRotation(settings.rotationIndex());
drawLauncher();
Serial.println("launcher ready");
settings.load(); // absent file keeps the built-in defaults
startApp(LAUNCHER);
}
void loop() {
if (!sdOk) return; // SD failed to mount; error already drawn
if (halted) return;
if (app.running()) {
app.loop();
if (!app.running()) {
appPath = "";
tft.setRotation(settings.rotationIndex()); // apps may have rotated the frame
drawLauncher();
Serial.println("launcher ready");
}
if (!app.running()) nextApp = app.takePendingLaunch();
return;
}
if (appPath.length() > 0) {
String path = appPath;
appPath = "";
Serial.printf("launching %s\n", path.c_str());
app.load(path.c_str());
return;
}
launcherTouch();
String path = nextApp.length() > 0 ? nextApp : String(LAUNCHER);
nextApp = "";
startApp(path);
delay(10);
}
+25 -5
View File
@@ -1,18 +1,28 @@
-- Run: lua test/settings_calibration.lua
-- Stubs the firmware bindings so the settings app loads on a desktop Lua.
package.path = "sdcard/lib/?.lua;" .. package.path
local saved
local rotation = 0
local now = 0
gui = {
color = function() return 0 end,
setRotation = function() end,
width = function() return 320 end,
height = function() return 480 end,
width = function() return rotation % 180 == 0 and 320 or 480 end,
height = function() return rotation % 180 == 0 and 480 or 320 end,
clear = function() end,
fillRect = function() end,
drawText = function() end,
drawRect = function() end,
drawLine = function() end,
fillRoundRect = function() end,
drawRoundRect = function() end,
fontHeight = function() return 8 end,
textWidth = function(text) return #text * 6 end,
setRotation = function() end,
}
sys = {
millis = function() return now end,
exit = function() end,
setCalibration = function(...) saved = {...} return true end,
getRotation = function() return rotation end,
@@ -23,6 +33,15 @@ log = {info = function() end}
dofile("sdcard/apps/settings/main.lua")
-- Menu rows: pad 12, gap 8, title 8 tall, buttons 24 tall.
local ROTATION_ROW = {x = 100, y = 70}
local CALIBRATE_ROW = {x = 100, y = 38}
local function tap(point)
on_touch_down(point.x, point.y)
on_touch_up(point.x, point.y)
end
-- A perfectly linear panel spanning raw 200..3800 over 320x480 must round-trip
-- to those same extremes from the two inset samples.
local inset, w, h = 30, 320, 480
@@ -45,12 +64,13 @@ assert(fx0 > fx1, "flipped axis should descend")
-- Rotation cycles through the four quarter turns and wraps back to 0.
setup()
for _, expected in ipairs({90, 180, 270, 0}) do
on_touch(20, 85)
tap(ROTATION_ROW)
assert(sys.getRotation() == expected, "rotation " .. sys.getRotation())
end
-- Calibration collects one sample per target and saves on the second release.
setup()
on_touch(20, 45)
tap(CALIBRATE_ROW)
on_tick() -- release after the menu tap arms sampling
input.getRawTouch = function() return s1.x, s1.y end
on_tick()
+93
View File
@@ -0,0 +1,93 @@
-- Run: lua test/ui_layout.lua
-- Stubs the firmware bindings and asserts the rects ui.lua computes.
package.path = "sdcard/lib/?.lua;" .. package.path
local FONT_HEIGHT, CHAR_WIDTH = 8, 6
local now = 0
local painted = {}
gui = {
color = function(r, g, b) return r * 65536 + g * 256 + b end,
width = function() return 320 end,
height = function() return 480 end,
clear = function() end,
fillRect = function() end,
drawText = function(label, x, y) painted[#painted + 1] = {label = label, x = x, y = y} end,
fillRoundRect = function() end,
drawRoundRect = function() end,
fontHeight = function() return FONT_HEIGHT end,
textWidth = function(text) return #text * CHAR_WIDTH end,
}
sys = {millis = function() return now end}
local ui = require("ui")
local function rect(node)
return string.format("%d,%d %dx%d", node.rect.x, node.rect.y, node.rect.w, node.rect.h)
end
-- Vertical flow: children fill the content width, stack by their own height plus gap.
local title = ui.text("settings")
local first = ui.button{label = "one", on_press = function() end}
local second = ui.button{label = "two", on_press = function() end}
local screen = ui.screen(ui.box{pad = 12, gap = 8, title, first, second})
assert(rect(title) == "12,12 296x8", rect(title))
-- Button height is text plus its own padding: 8 + 8 + 8 = 24.
assert(rect(first) == "12,28 296x24", rect(first))
assert(rect(second) == "12,60 296x24", rect(second))
-- Fractions resolve against the parent content box, absolutes stay absolute.
local half = ui.box{w = 0.5, h = 40}
local fixed = ui.box{w = 100, h = 40}
ui.screen(ui.box{pad = 10, half, fixed})
assert(rect(half) == "10,10 150x40", rect(half))
assert(rect(fixed) == "10,50 100x40", rect(fixed))
-- Horizontal flow with centre alignment on the cross axis of a fixed-height row.
local tall = ui.box{w = 40, h = 40}
local short = ui.box{w = 40, h = 10}
local row = ui.box{row = true, gap = 6, align = "center", h = 40, tall, short}
ui.screen(ui.box{row})
assert(rect(tall) == "0,0 40x40", rect(tall))
assert(rect(short) == "46,15 40x10", rect(short))
-- The root always fills the screen, so alignment there spans the whole panel.
local lone = ui.box{w = 40, h = 40}
ui.screen(ui.box{align = "center", lone})
assert(rect(lone) == "140,0 40x40", rect(lone))
-- A fraction inside an auto-sized parent is a build-time error, not a silent zero.
local ok = pcall(function()
ui.screen(ui.box{ui.box{h = "auto", ui.box{w = 20, h = 0.5}}})
end)
assert(not ok, "fraction inside an auto parent must fail loudly")
-- Hit testing: deepest interactive component wins over its tappable ancestor.
local inner = ui.button{label = "inner", on_press = function() end}
local outer = ui.box{pad = 20, on_press = function() end, inner}
local nested = ui.screen(outer)
assert(outer:hit(160, 30) == inner, "inner button should win inside its rect")
assert(outer:hit(2, 2) == outer, "the container claims its own padding")
assert(outer:hit(-50, -50) == nil, "outside the tree is a miss")
-- Capture: press then release outside cancels; release inside fires once.
local fired = 0
local button = ui.button{label = "go", on_press = function() fired = fired + 1 end}
local app = ui.screen(ui.box{pad = 10, button})
app:down(100, 20)
assert(button.pressed, "press must show immediately")
app:up(300, 470)
assert(fired == 0, "release outside must cancel")
app:down(100, 20)
app:up(100, 20)
assert(fired == 1, "release inside must fire once")
-- The pressed look is held briefly, then cleared on a later draw.
assert(button.pressed, "pressed look must outlast the release")
now = now + 100
app:draw()
assert(not button.pressed, "pressed look must clear after the hold")
print("ok")