Files
slate32/sdcard/lib/ui.lua
T
evan 932d41aeeb revert(ui): remove scrollable box
Keep raw touch-move events for direct gesture consumers such as Paint. Toolkit screens remain frame-sized to avoid redraw and heap costs.
2026-08-02 17:44:39 -04:00

542 lines
19 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
self.dirtyParts = nil
for _, child in ipairs(self.children) do child.dirty = true end
elseif self.dirtyParts then
local parts = self.dirtyParts
self.dirtyParts = nil
for part in pairs(parts) do self:paint_part(part) 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
function Component:invalidatePart(part)
if part == nil or not self.paint_part then return self:invalidate() end
if self.dirty then return end
self.dirtyParts = self.dirtyParts or {}
self.dirtyParts[part] = 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)
local x = self.rect.x
if self.text_align == "center" then x = x + (self.rect.w - gui.getTextWidth(self.label)) // 2
elseif self.text_align == "end" then x = x + self.rect.w - gui.getTextWidth(self.label) end
gui.drawText(self.label, 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 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 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
local function invalidatePressed(target, part)
if target.invalidate_press then target:invalidate_press(part) else target:invalidate() end
end
local function finishRelease(screen)
local released = screen.released
if not released then return end
released.target.pressed = false
invalidatePressed(released.target, released.part)
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
self.root:draw()
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 changing a custom component's active sub-part.
finishRelease(self)
local target = self.root:hit(x, y)
if not target then return end
self.captured = target
self.pressedAt = sys.getMillis()
self.capturedPart = target.on_down and target.on_down(target, x, y) or nil
if target.press_style ~= false then
target.pressed = true
invalidatePressed(target, self.capturedPart)
end
end
function Screen:up(x, y)
local target, part = self.captured, self.capturedPart
self.captured, self.capturedPart = nil, nil
if not target then return end
if target.press_style ~= false then
self.released = {target = target, part = part, pressedAt = self.pressedAt}
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