Files
slate32/sdcard/lib/ui.lua
T
evan 4e5796fd51 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.
2026-07-31 22:27:34 -04:00

253 lines
7.5 KiB
Lua

-- 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