4e5796fd51
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.
253 lines
7.5 KiB
Lua
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
|