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
+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")