diff --git a/.pi/skills/test-e32r40t-firmware/SKILL.md b/.pi/skills/test-e32r40t-firmware/SKILL.md index 476f44d..ee8e2ff 100644 --- a/.pi/skills/test-e32r40t-firmware/SKILL.md +++ b/.pi/skills/test-e32r40t-firmware/SKILL.md @@ -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//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//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: diff --git a/README.md b/README.md index 1d3163a..2d54c53 100644 --- a/README.md +++ b/README.md @@ -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//main.lua`). +Copy `sdcard/` to the SD card root: apps live in `/apps//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//?.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 diff --git a/sdcard/apps/launcher/main.lua b/sdcard/apps/launcher/main.lua new file mode 100644 index 0000000..f10e092 --- /dev/null +++ b/sdcard/apps/launcher/main.lua @@ -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 diff --git a/sdcard/apps/settings/main.lua b/sdcard/apps/settings/main.lua index 53ef0e6..6f6ec08 100644 --- a/sdcard/apps/settings/main.lua +++ b/sdcard/apps/settings/main.lua @@ -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 diff --git a/sdcard/lib/ui.lua b/sdcard/lib/ui.lua new file mode 100644 index 0000000..329a0e0 --- /dev/null +++ b/sdcard/lib/ui.lua @@ -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 diff --git a/src/lua/lua_app.cpp b/src/lua/lua_app.cpp index d71acfa..abc38b3 100644 --- a/src/lua/lua_app.cpp +++ b/src/lua/lua_app.cpp @@ -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; diff --git a/src/lua/lua_app.h b/src/lua/lua_app.h index 0c3b104..3952159 100644 --- a/src/lua/lua_app.h +++ b/src/lua/lua_app.h @@ -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; diff --git a/src/main.cpp b/src/main.cpp index 4835b1c..903b711 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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); } diff --git a/test/settings_calibration.lua b/test/settings_calibration.lua index c468bea..c32bf5d 100644 --- a/test/settings_calibration.lua +++ b/test/settings_calibration.lua @@ -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() diff --git a/test/ui_layout.lua b/test/ui_layout.lua new file mode 100644 index 0000000..93c4259 --- /dev/null +++ b/test/ui_layout.lua @@ -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")