Files
slate32/sdcard/lib/ui.lua
T
evan b5146b8642 refactor(lua): give the status bar its own dirty tracking and split settings out of sys
The bar repainted itself whole every second. It now compares each field against what
it last painted, adds seconds and a memory percentage, and keys the cache on
gui.getRotation() and ui.themeName so rotation and theme changes still repaint it.
Invalidation lives entirely in Lua; the firmware's push flag and gfx/statusbar.h are gone.

Bindings follow getName/setName/isName, persisted preferences move from sys to a settings
table, and gui.setRotation takes degrees like settings does. A bar that dies mid-run now
keeps its rows reserved rather than silently resizing the running app.
2026-08-02 10:49:01 -04:00

512 lines
18 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), "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()
local function resolve(value, span, axis)
if value == nil or value == "auto" then return nil end
-- "fill" exists because a fraction cannot say 1.0: any number >= 1 is a pixel count,
-- so w = 1.0 asks for a single pixel. Covering a parent needs a word, not a number.
if value == "fill" then
if span == nil then
error("fill " .. axis .. " inside an auto-sized parent", 3)
end
return span
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", "dimmed"}
-- The root seeds the tree, so an app that names no colors is themed by inheritance.
local ROOT_STYLE = {
color = "fg", bg = "bg", press_bg = "accent", press_color = "accent_fg",
gradient = "face", press_gradient = "face_pressed", radius = "radius",
}
local function seedFrom(node, palette, style)
for key, role in pairs(ROOT_STYLE) do
if node[key] == nil then node[key] = (style and style[key]) or palette[role] end
end
end
local function inherit(child, parent)
-- Crossing into or out of a dimmed region restyles from the matching palette instead
-- of inheriting its neighbour's, which is how a dialog stays lit over dimmed content.
if child.dimmed ~= nil and child.dimmed ~= parent.dimmed then
seedFrom(child, child.dimmed and ui.theme.dim or ui.theme)
end
for _, key in ipairs(INHERITED) do
if child[key] == nil then child[key] = parent[key] end
end
-- What this node sits on, which is not the same as what it fills. Anti-aliased edges
-- blend into the surface, so a rounded card needs the color behind it and not its own.
if child.surface == nil then child.surface = parent.bg or parent.surface 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,
}
-- Main-axis distribution, CSS justify-content minus the modes nothing here asks for.
-- Absolutely placed children take no part in the flow, so they are excluded.
local flowing, used = 0, 0
for _, child in ipairs(self.children) do
if not child.at then
flowing = flowing + 1
used = used + (self.row and child.mw or child.mh)
end
end
used = used + math.max(0, flowing - 1) * self.gap
local free = math.max(0, (self.row and content.w or content.h) - used)
local offset, spread = 0, 0
if self.justify == "end" then offset = free
elseif self.justify == "center" then offset = math.floor(free / 2)
elseif self.justify == "between" and flowing > 1 then spread = math.floor(free / (flowing - 1))
end
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 + spread
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 + spread
end
child:place{x = x, y = y, w = cw, h = ch}
end
end
function Component:draw()
if self.dirty then
if self.bg and not self.paintsBackground 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
-- A capturing component swallows the taps its children missed, so what it covers
-- cannot be tapped through. Without it the hit walk falls back to earlier siblings,
-- which is how a dialog would let you press the button underneath it.
if self.capture then return self end
return self.on_press and self or nil
end
function Component:invalidate()
self.dirty = true
end
local function component(spec)
-- A bordered box is drawn as a rounded rect over its own background fill. The fill is
-- square and the border is not, but both are the same color as whatever sits behind a
-- box on this panel, so the corners have nothing to give away.
-- A bordered box paints its own background as a rounded rect, so the square fill in
-- Component:draw is suppressed. Filling first would leave square corners outside the
-- border, which is invisible against a matching surface and obvious against any other.
if spec.border then
spec.paintsBackground = true
spec.paint = function(self)
local r = self.rect
gui.roundRect(r.x, r.y, r.w, r.h, self.radius or ui.theme.radius, self.surface,
self.bg, self.bg, self.border)
end
end
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
-- Text size is panel state, not node state, so every measure and paint sets it: a
-- neighbour at another size would otherwise decide this node's metrics.
local function measureText(self, available)
gui.setTextSize(self.size or 1)
self.mw = resolve(self.w, available.w, "width") or gui.getTextWidth(self.label)
self.mh = resolve(self.h, available.h, "height") or gui.getFontHeight()
return self.mw, self.mh
end
local function paintText(self)
gui.setTextSize(self.size or 1)
gui.drawText(self.label, self.rect.x, self.rect.y, self.color, self.bg)
end
local function setText(self, text)
if text == self.label then return end
self.label = text
self:invalidate()
end
function ui.text(label, spec)
spec = spec or {}
spec.label = label
local node = component(spec)
node.measure = measureText
node.paint = paintText
node.setText = setText
return node
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 3 or 2
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 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
local function paintButton(self)
local r = self.rect
local gradient = self.pressed and self.press_gradient or (not self.pressed and self.gradient)
local top, bottom
if gradient then
top, bottom = gradient[1], gradient[2]
else
top = self.pressed and (self.press_bg or self.color) or self.button_bg
bottom = top
end
gui.roundRect(r.x, r.y, r.w, r.h, self.radius or ui.theme.radius, self.bg, top, bottom, self.color)
for _, child in ipairs(self.children) do
-- No background for a gradient face: an opaque glyph fill is one flat colour, which
-- only matches the one row of the gradient it was taken from. The face is repainted
-- above on every change, so the children have nothing to erase.
-- `x and nil or y` can never be nil, so this stays an if.
if gradient then child.bg = nil else child.bg = bottom or self.bg end
child.color = self.pressed and (self.press_color or self.bg) or self.color
child.dirty = true
end
end
local function setButtonText(self, text)
local label = self.children[1]
if text == label.label then return end
label:setText(text)
self:invalidate()
end
function ui.button(spec)
spec.pad = spec.pad or 8
spec.align = spec.align or "center"
local hasLabel = spec.label ~= nil
if hasLabel then
spec.children = {ui.text(spec.label)}
spec.label = nil
end
local node = component(spec)
node.paint = paintButton
if hasLabel then node.setText = setButtonText end
return node
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 = false leaves the app's own screen visible around the card; capture stops it
-- being tapped. on_press is deliberately absent, so a stray touch answers nothing.
return ui.box{
at = {x = 0, y = 0}, w = "fill", h = "fill",
bg = false, 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
function ui.screen(root, style)
local screen = setmetatable({root = root, captured = nil, pressedAt = 0}, Screen)
-- 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.
if root.w == nil then root.w = "fill" end
if root.h == nil then root.h = "fill" end
seedFrom(root, root.dimmed and ui.theme.dim or ui.theme, style)
screen:relayout()
return screen
end
function Screen:relayout()
local rect = {x = 0, y = 0, w = gui.getWidth(), h = gui.getHeight()}
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.getMillis() - 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.getMillis()
if target.press_style ~= false then
target.pressed = true
target:invalidate()
end
end
function Screen:up(x, y)
local target = self.captured
self.captured = nil
if not target then return end
if target.press_style ~= false then self.released = target end
local inside = contains(target.rect, x, y)
if inside and target.on_press then target.on_press(target, x, y) end
end
return ui