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:
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user