f7c5cc09ba
A node was a Lua table of ~625 bytes, of which 21 keys pushed it over a
power-of-two hash boundary and eight were style copies inheritance had
splattered down from its parent. A 400 node screen cost ~250 KB and could not
coexist with wifi's buffers.
The tree now lives in src/ui/layout.h as a 16 byte struct in a flat arena, and
splits by lifetime: Node holds what hit testing and repainting need forever,
Spec holds what only measure/place read and is dropped when layout ends. Style
is sparse and resolved by walking parents, so a node naming no colours costs
nothing. Re-layout rebuilds from Lua rather than retaining the inputs.
401 nodes: 8218 B steady, 21050 B peak
Lua: ~250000 B steady
sdcard/lib/ui.lua stays the toolkit and keeps every constructor signature, but
returns integer handles: 627 lines to 374. Composition, the palette and custom
painters are still Lua on the SD card; only primitives now need a reflash.
BREAKING CHANGE: ui constructors return handles, not tables. Use
ui.setText(id, text) and keep per-node app data in a table keyed by id.
375 lines
13 KiB
Lua
375 lines
13 KiB
Lua
-- Widgets and palette. The tree itself lives in the firmware: every constructor here
|
|
-- returns an integer handle, and the C++ arena holds sixteen bytes where a Lua table
|
|
-- held several hundred.
|
|
--
|
|
-- Layout is borrowed from CSS block flow: nesting plus box model, no cascade. Sizes are
|
|
-- pixels (>= 1), a fraction of the parent content box (< 1), "fill" for all of it, or
|
|
-- "auto" to size to content.
|
|
|
|
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
|
|
|
|
-- Theme --------------------------------------------------------------------
|
|
-- Inheritance carries context (what surface am I on); the theme carries constants
|
|
-- (what is the accent). Roles are derived from three seeds so a new component costs
|
|
-- no theme keys, and so a theme cannot state its own contrast wrongly.
|
|
|
|
local BLACK, WHITE = {0, 0, 0}, {255, 255, 255}
|
|
local FALLBACK = {bg = WHITE, fg = BLACK, accent = {0, 120, 255}}
|
|
local PINNED = {muted = "color", accent_fg = "color", face = "pair", face_pressed = "pair", radius = "number"}
|
|
|
|
local function channel(value)
|
|
return math.max(0, math.min(math.floor(value + 0.5), 255))
|
|
end
|
|
|
|
local function mix(a, b, amount)
|
|
local blended = {}
|
|
for i = 1, 3 do blended[i] = channel(a[i] + (b[i] - a[i]) * amount) end
|
|
return blended
|
|
end
|
|
|
|
-- Perceived brightness, so text on the accent is chosen rather than configured.
|
|
local function luminance(c)
|
|
return (c[1] * 299 + c[2] * 587 + c[3] * 114) / 1000
|
|
end
|
|
|
|
local function rgb(c)
|
|
return gui.color(c[1], c[2], c[3])
|
|
end
|
|
|
|
local function palette(seed)
|
|
local bg, fg, accent = seed.bg, seed.fg, seed.accent
|
|
local theme = {
|
|
bg = rgb(bg),
|
|
fg = rgb(fg),
|
|
muted = rgb(mix(fg, bg, 0.45)),
|
|
accent = rgb(accent),
|
|
accent_fg = rgb(luminance(accent) > 140 and BLACK or WHITE),
|
|
face = {rgb(bg), rgb(mix(bg, fg, 0.08))},
|
|
face_pressed = {rgb(mix(accent, WHITE, 0.12)), rgb(mix(accent, BLACK, 0.12))},
|
|
radius = 6,
|
|
}
|
|
for role, kind in pairs(PINNED) do
|
|
local pin = seed[role]
|
|
if pin ~= nil then
|
|
if kind == "color" then theme[role] = rgb(pin)
|
|
elseif kind == "pair" then theme[role] = {rgb(pin[1]), rgb(pin[2])}
|
|
else theme[role] = pin end
|
|
end
|
|
end
|
|
return theme
|
|
end
|
|
|
|
-- Dimming is a black scrim at this strength. The panel has no alpha and there is no
|
|
-- framebuffer to blend against, so instead of compositing pixels the whole palette is
|
|
-- re-derived from darkened seeds: content behind a dialog is repainted in those colors,
|
|
-- which is what a scrim would have produced anyway.
|
|
local SCRIM = 0.45
|
|
|
|
local function dimSeed(seed)
|
|
local out = {}
|
|
for role, value in pairs(seed) do
|
|
if type(value) ~= "table" then out[role] = value
|
|
elseif type(value[1]) == "table" then out[role] = {mix(value[1], BLACK, SCRIM), mix(value[2], BLACK, SCRIM)}
|
|
else out[role] = mix(value, BLACK, SCRIM) end
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function themes()
|
|
local ok, loaded = pcall(require, "theme")
|
|
return ok and type(loaded) == "table" and loaded or {}
|
|
end
|
|
|
|
function ui.themeNames()
|
|
local names = {}
|
|
for name in pairs(themes()) do names[#names + 1] = name end
|
|
table.sort(names)
|
|
return names
|
|
end
|
|
|
|
-- Saves the theme and reloads the palette. The firmware stores only the name and cannot
|
|
-- call back into Lua, so this is the seam that keeps the two halves from drifting.
|
|
function ui.setTheme(name)
|
|
local ok = settings.setTheme(name)
|
|
ui.reloadTheme()
|
|
return ok
|
|
end
|
|
|
|
-- Re-read the saved theme. Called at load, and by ui.setTheme(), so a changed theme takes
|
|
-- effect without relaunching.
|
|
function ui.reloadTheme()
|
|
ui.themeName = settings.getTheme and settings.getTheme() or "light"
|
|
local available = themes()
|
|
local seed = available[ui.themeName] or available.light or FALLBACK
|
|
ui.theme = palette(seed)
|
|
ui.theme.dim = palette(dimSeed(seed))
|
|
return ui.theme
|
|
end
|
|
|
|
ui.reloadTheme()
|
|
|
|
-- Handlers -----------------------------------------------------------------
|
|
-- Callbacks stay in Lua, keyed by node id. Ids are handed out densely from zero, so
|
|
-- these are array parts rather than hashes, which is cheaper than a registry reference
|
|
-- and leaves nothing to release.
|
|
|
|
local press, down, unpress, painters, flat = {}, {}, {}, {}, {}
|
|
-- Which nodes were given a whole palette, and which one. A root that already chose the
|
|
-- dim palette must not be reseeded with the lit one, and the panel is cleared to whatever
|
|
-- the root resolved to rather than to the theme's own background.
|
|
local paletted = {}
|
|
|
|
-- Set once a tree has been laid out, so the next node built starts a new screen. Apps
|
|
-- rebuild rather than mutate, and making that automatic is what keeps a forgotten reset
|
|
-- from quietly growing the arena one screen at a time.
|
|
local laidOut = false
|
|
|
|
local function forget()
|
|
press, down, unpress, painters, flat = {}, {}, {}, {}, {}
|
|
paletted = {}
|
|
end
|
|
|
|
-- Every node's paint routine is looked up here, so one dispatcher serves the whole tree.
|
|
node.setPainter(function(id, x, y, w, h)
|
|
local paint = painters[id]
|
|
if paint then paint(id, x, y, w, h) end
|
|
end)
|
|
|
|
-- Components ---------------------------------------------------------------
|
|
|
|
local STYLE_KEYS = {"color", "border", "face", "face_pressed", "press_color",
|
|
"radius", "size", "text_align"}
|
|
|
|
-- Applies a whole palette to one node. Descendants inherit it by walking up, so a
|
|
-- dimmed region and the lit dialog above it are each one call, not a tree walk.
|
|
local function applyPalette(id, theme)
|
|
paletted[id] = theme
|
|
node.setStyle(id, {
|
|
color = theme.fg, bg = theme.bg,
|
|
face = theme.face, face_pressed = theme.face_pressed,
|
|
press_color = theme.accent_fg, radius = theme.radius,
|
|
})
|
|
end
|
|
|
|
local function applyStyle(id, spec)
|
|
if spec.dimmed ~= nil then
|
|
applyPalette(id, spec.dimmed and ui.theme.dim or ui.theme)
|
|
end
|
|
local style, any = {}, false
|
|
for _, key in ipairs(STYLE_KEYS) do
|
|
if spec[key] ~= nil then style[key], any = spec[key], true end
|
|
end
|
|
-- A box that names a background both paints it and offers it to its children; `false`
|
|
-- says to paint nothing, which is how a dialog layer covers without covering up.
|
|
if type(spec.bg) == "number" then
|
|
style.bg, style.fill, any = spec.bg, spec.bg, true
|
|
end
|
|
if any then node.setStyle(id, style) end
|
|
end
|
|
|
|
local function build(spec, kind)
|
|
if laidOut then ui.reset() end
|
|
local children = {}
|
|
for index, child in ipairs(spec) do
|
|
children[index] = child
|
|
spec[index] = nil
|
|
end
|
|
spec.type = kind
|
|
spec.interactive = spec.on_press ~= nil or spec.on_down ~= nil
|
|
local id = node.create(nil, spec)
|
|
for _, child in ipairs(children) do node.attach(id, child) end
|
|
|
|
applyStyle(id, spec)
|
|
if spec.on_press then press[id] = spec.on_press end
|
|
if spec.on_down then down[id] = spec.on_down end
|
|
if spec.on_unpress then unpress[id] = spec.on_unpress end
|
|
if spec.paint then painters[id] = spec.paint end
|
|
if spec.press_style == false then flat[id] = true end
|
|
return id
|
|
end
|
|
|
|
function ui.box(spec)
|
|
return build(spec, "box")
|
|
end
|
|
|
|
function ui.spacer(spec)
|
|
return build({w = spec.w, h = spec.h}, "box")
|
|
end
|
|
|
|
function ui.text(label, spec)
|
|
spec = spec or {}
|
|
spec.label = label
|
|
return build(spec, "text")
|
|
end
|
|
|
|
-- A text node sized to its own glyphs, so a centering parent has something to center: a
|
|
-- plain ui.text takes the parent's full width and paints from its left edge. `fit` clips
|
|
-- the label to a pixel budget, because the panel has one font and no wrapping.
|
|
function ui.label(text, spec)
|
|
spec = spec or {}
|
|
gui.setTextSize(spec.size or 1)
|
|
if spec.fit and gui.getTextWidth(text) > spec.fit then
|
|
while #text > 1 and gui.getTextWidth(text .. "~") > spec.fit do text = text:sub(1, -2) end
|
|
text = text .. "~"
|
|
end
|
|
spec.w, spec.fit = gui.getTextWidth(text), nil
|
|
return ui.text(text, spec)
|
|
end
|
|
|
|
function ui.button(spec)
|
|
spec.pad = spec.pad or 8
|
|
spec.align = spec.align or "center"
|
|
local label, size = spec.label, spec.size
|
|
spec.label = nil
|
|
local id = build(spec, "button")
|
|
-- The label is created after its button, so it needs no adoption.
|
|
if label then node.create(id, {type = "text", label = label, size = size}) end
|
|
return id
|
|
end
|
|
|
|
-- A node that paints itself through the gui bindings. One node instead of a table per
|
|
-- part is what keeps a full keyboard off the heap while wifi is up.
|
|
function ui.custom(spec)
|
|
return build(spec, "custom")
|
|
end
|
|
|
|
-- Replaces a node's text. The node keeps the box it was placed with, so this is for
|
|
-- values that fit the space already reserved for them.
|
|
function ui.setText(id, text)
|
|
if node.getLabel(id) == text then return end
|
|
node.setLabel(id, text)
|
|
node.invalidate(id)
|
|
end
|
|
|
|
function ui.invalidate(id)
|
|
node.invalidate(id)
|
|
end
|
|
|
|
-- Geometry for a grid of square cards: a wide frame takes another column, and the side
|
|
-- shrinks until every row fits, because there is no scrolling and a card below the fold
|
|
-- cannot be tapped. `reserve` is height the caller needs for anything under the grid.
|
|
-- ponytail: enough cards make them unusably small; that is the point to add scrolling.
|
|
function ui.cardSide(count, pad, gap, reserve)
|
|
local cols = gui.getWidth() >= gui.getHeight() and 4 or 3
|
|
local rows = math.ceil(count / cols)
|
|
local byWidth = (gui.getWidth() - 2 * pad - (cols - 1) * gap) // cols
|
|
local byHeight = (gui.getHeight() - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows
|
|
return math.min(byWidth, byHeight), cols
|
|
end
|
|
|
|
-- A dialog is not a layer the toolkit manages: it is a node an app includes when its
|
|
-- state calls for one, placed absolutely so it covers the flow instead of joining it.
|
|
-- Dismissing it means rebuilding without it, the same way every other state change works.
|
|
function ui.confirm(spec)
|
|
local card = {
|
|
w = spec.w or 0.85,
|
|
pad = 16,
|
|
gap = 12,
|
|
bg = ui.theme.bg, -- opaque: this is what hides the content behind
|
|
border = spec.border or ui.theme.muted,
|
|
ui.text(spec.title),
|
|
}
|
|
if spec.message then card[#card + 1] = ui.text(spec.message, {color = ui.theme.muted}) end
|
|
|
|
local buttons = {row = true, gap = 8, justify = "end"}
|
|
if spec.cancel ~= false then
|
|
buttons[#buttons + 1] = ui.button{label = spec.cancel or "cancel", on_press = spec.on_cancel}
|
|
end
|
|
buttons[#buttons + 1] = ui.button{label = spec.ok or "ok", on_press = spec.on_ok}
|
|
card[#card + 1] = ui.box(buttons)
|
|
|
|
-- bg is left unset so the app's own screen stays visible around the card; capture stops
|
|
-- it being tapped. on_press is optional, so a stray touch answers nothing by default.
|
|
return ui.box{
|
|
at = {x = 0, y = 0}, w = "fill", h = "fill",
|
|
capture = true, dimmed = false, -- the card is lit, whatever is behind it
|
|
align = "center", justify = "center",
|
|
on_press = spec.on_outside,
|
|
ui.box(card),
|
|
}
|
|
end
|
|
|
|
-- Screen -------------------------------------------------------------------
|
|
|
|
local Screen = {}
|
|
Screen.__index = Screen
|
|
|
|
-- Starts a new tree. Every screen is built from scratch, which is why the heap returns to
|
|
-- the same shape after each one instead of fragmenting.
|
|
function ui.reset()
|
|
node.reset()
|
|
forget()
|
|
laidOut = false
|
|
end
|
|
|
|
function ui.screen(root, style)
|
|
-- The root is placed at the full panel rect, so it must measure that way too. Leaving
|
|
-- it auto made its height unknown to its own children, and a child asking for a
|
|
-- fraction of the screen failed inside the one component whose size is never in doubt.
|
|
node.setSize(root, "fill", "fill")
|
|
if not paletted[root] then applyPalette(root, ui.theme) end
|
|
if style then node.setStyle(root, style) end
|
|
|
|
local screen = setmetatable({root = root, captured = nil, pressedAt = 0}, Screen)
|
|
screen:relayout()
|
|
return screen
|
|
end
|
|
|
|
function Screen:relayout()
|
|
local ok, err = node.layout(self.root, 0, 0, gui.getWidth(), gui.getHeight())
|
|
if not ok then error(err, 2) end
|
|
node.dropScratch()
|
|
laidOut = true
|
|
gui.clear((paletted[self.root] or ui.theme).bg)
|
|
end
|
|
|
|
local function finishRelease(screen)
|
|
local released = screen.released
|
|
if not released then return end
|
|
if unpress[released.target] then
|
|
unpress[released.target](released.target)
|
|
else
|
|
node.setPressed(released.target, false)
|
|
end
|
|
screen.released = nil
|
|
end
|
|
|
|
function Screen:draw()
|
|
-- Same-Frame Release - Queue the normal state before drawing so it does not wait for
|
|
-- another frame.
|
|
if self.released and sys.getMillis() - self.released.pressedAt >= PRESS_MS then
|
|
finishRelease(self)
|
|
end
|
|
node.draw(self.root)
|
|
end
|
|
|
|
function Screen:down(x, y)
|
|
-- Overlapping Presses - A second tap can begin during the first tap's 80 ms hold.
|
|
-- Finish the old visual before a custom component changes what it is highlighting.
|
|
finishRelease(self)
|
|
local target = node.hit(self.root, x, y)
|
|
if not target then return end
|
|
self.captured = target
|
|
self.pressedAt = sys.getMillis()
|
|
if down[target] then down[target](target, x, y) end
|
|
if not flat[target] then node.setPressed(target, true) end
|
|
end
|
|
|
|
function Screen:up(x, y)
|
|
local target = self.captured
|
|
self.captured = nil
|
|
if not target then return end
|
|
if not flat[target] or unpress[target] then
|
|
self.released = {target = target, pressedAt = self.pressedAt}
|
|
end
|
|
local rx, ry, rw, rh = node.getRect(target)
|
|
local inside = x >= rx - SLOP and x < rx + rw + SLOP
|
|
and y >= ry - SLOP and y < ry + rh + SLOP
|
|
if inside and press[target] then press[target](target, x, y) end
|
|
end
|
|
|
|
return ui
|