Files
esp32-lua-api/lua/lib/ui.lua
T
evan 5d44234046 perf(ui): pass the label as an argument and share one empty spec
The label was the last field the wrapper wrote into a caller's spec, and on
a two-key text spec it was the key that forced a rehash. It now rides as an
argument like type, so ui.text writes nothing at all, and a button's padding
and centring are the tree's defaults rather than fields patched in from Lua.

With no builder writing into a spec any more, every spec-less call can share
one immutable table instead of allocating its own. It is frozen with a
__newindex that raises, because reintroducing a write would otherwise leak a
field into every spec-less node built afterwards -- a fault with no symptom
anywhere near its cause. One lua_State exists at a time, so the guard costs
about 100 bytes in total.

ui.spacer passes its spec through rather than copying w and h into a fresh
table, and the style alias on a text spec is dropped for textStyle, which no
caller used.

ui.rebuild now collects before the repaint rather than after it. The collect
was already there and its comment already named the hazard, but the painter
is the very next thing to want a large contiguous block for its band, and it
was being handed a heap still holding a screen's worth of dead spec tables --
a C++ allocation gets no emergency collection the way a failed Lua one does.
Worth 5.6kB of free heap at paint time; the largest block is unchanged,
because the freed specs are small and scattered.

Measured in the emulator, 12 sensors / 131 nodes: live Lua at build end
79.9kB -> 79.7kB and build time unchanged at 52ms. The raw heap figure looks
worse because removing the rehashes also removed the allocation pressure that
had been pacing the incremental collector, so the dead spec tables now sit
uncollected until something asks for them; live usage is what did not change.
ui.lua stripped bytecode 9499 -> 9340 bytes.
2026-08-08 12:19:53 -04:00

725 lines
22 KiB
Lua

local ui = {}
---@alias UiHandler fun(id: NodeId, x?: integer, y?: integer)
---@class UiSpec
---@field [integer] NodeId Child nodes.
---@field w? number|"fill"|"auto"
---@field h? number|"fill"|"auto"
---@field pad? number
---@field gap? number
---@field align? "start"|"center"|"end"|"stretch"
---@field justify? "start"|"center"|"end"|"between"
---@field row? boolean
---@field at? table
---@field label? string
---@field font? GuiFont
---@field fit? integer Maximum label width.
---@field background? GuiColor
---@field face? GuiColor
---@field pressedFace? GuiColor
---@field pressedColor? GuiColor
---@field focusColor? GuiColor
---@field focusWidth? integer
---@field textStyle? GuiTextStyle
---@field on_enter? UiHandler
---@field on_exit? UiHandler
---@field on_click? UiHandler
---@field paint? fun(id: NodeId, x: integer, y: integer, w: integer, h: integer, clipX: integer, clipY: integer, clipW: integer, clipH: integer)
---@field press_style? boolean False for a widget that paints its own press feedback.
---@field scrollX? boolean Children measure unbounded across, and the box pans horizontally.
---@field scrollY? boolean Children measure unbounded down, and the box pans vertically.
---@class UiConfirmSpec
---@field title string
---@field message? string
---@field ok? string
---@field cancel? string|false
---@field w? number
---@field background? GuiColor
---@field border? GuiColor
---@field on_ok? UiHandler
---@field on_cancel? UiHandler
---@field on_outside? UiHandler
local THEMES = {
light = { background = { 255, 255, 255 }, color = { 0, 0, 0 }, accent = { 0, 120, 255 }, radius = 6 },
dark = { background = { 18, 18, 20 }, color = { 235, 235, 235 }, accent = { 166, 118, 255 }, radius = 6 },
mono = { background = { 255, 255, 255 }, color = { 0, 0, 0 }, accent = { 0, 0, 0 }, radius = 0 },
}
local enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {}
-- A widget that paints its own press feedback opts out, so pressing one key does not
-- repaint the whole node the way a pressed style would. Only the opt-outs are stored:
-- keyed the other way this held one entry per node, which on a full screen is a hash
-- part of several kB recording that almost nothing is an exception.
local noPressStyle = {}
local laidOut = false
local themeName
-- One tree per state, built by the function ui.mount() was given. Rebuilding is
-- cheap enough that nothing is retained between screens.
local builder, root, responder, insideResponder, confirming
local inset = 0
local applyPalette
-- Scroll gestures live in the tree, not the app: a drag on any scrollX/scrollY box pans it,
-- a flick coasts, and a child button's tap is suppressed once the drag passes SLOP. C++ owns
-- only the clamped offset (setScroll/getScroll); the momentum and the tap-vs-pan decision are
-- here, keyed by node so every scrollable box gets them for free.
local SCROLL_SLOP = 8
local SCROLL_DECAY = 0.02
local SCROLL_STOP = 8
-- id -> {x=bool, y=bool}: which axes a box pans. Recorded at build, cleared on reset.
local scrollNodes = {}
-- id -> {sx, sy, vx, vy}: pan offset (float, floored into setScroll) and flick velocity.
local scrollState = {}
-- Set of ids still coasting, iterated each draw until they settle.
local flinging = {}
-- Per-gesture bookkeeping: the box being panned once past SLOP, and the raw drag deltas.
local panning, scrollAncestor, dragged
local downX, downY, lastX, lastY, pendingX, pendingY
local function mix(a, b, amount)
local out = {}
for i = 1, 3 do
out[i] = math.floor(a[i] + (b[i] - a[i]) * amount + 0.5)
end
return out
end
local function color(rgb)
return screen.color(rgb[1], rgb[2], rgb[3])
end
local function palette(seed)
local background, foreground, accent = seed.background, seed.color, seed.accent
return {
background = color(background),
color = color(foreground),
muted = color(mix(foreground, background, 0.45)),
accent = color(accent),
disabled = color(mix(background, foreground, 0.25)),
face = color(mix(background, foreground, 0.08)),
pressedFace = color(accent),
pressedColor = color(background),
focusColor = color(accent),
radius = seed.radius,
}
end
local function loadTheme(name)
themeName = THEMES[name] and name or "light"
ui.theme = palette(THEMES[themeName])
end
---@return string
function ui.getTheme()
return themeName
end
---@return string[]
function ui.themeNames()
local names = {}
for name in pairs(THEMES) do
names[#names + 1] = name
end
table.sort(names)
return names
end
---@param name string
---@return true? ok
---@return string? error
function ui.setTheme(name)
if not THEMES[name] then
return nil, "Unknown theme"
end
local ok, err = screen.setTheme(name)
if not ok then
return nil, err
end
loadTheme(name)
if root then
applyPalette(root)
screen.clear(ui.theme.background)
tree.invalidate(root)
end
return true
end
loadTheme(screen.getTheme())
local function clearState()
enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {}
noPressStyle = {}
end
-- The clip is the slice of the node being repainted now, which for a composited repaint is
-- one band. A painter that ignores it still draws correctly; one that culls to it stops
-- redrawing its whole contents once per band it spans.
tree.setPainter(function(id, x, y, w, h, clipX, clipY, clipW, clipH)
local painter = painters[id]
if painter then
painter(id, x, y, w, h, clipX, clipY, clipW, clipH)
end
end)
-- Shared by every builder called without a spec. Safe only because nothing writes
-- into a spec any more: type, interactive and label are arguments, and a button's
-- padding is the tree's default rather than something patched in here. Frozen so
-- that reintroducing a write fails at the write instead of leaking a field into
-- every spec-less node built afterwards, which is a fault with no symptom near it.
local EMPTY = setmetatable({}, {
__newindex = function()
error("ui specs are read-only during a build", 2)
end,
})
local function build(spec, kind, label)
spec = spec or EMPTY
-- Nodes outside a build would reset the arena under the screen already on the
-- panel, chrome included. Rebuilding is the only way to change one.
if laidOut then
error("build nodes from the function ui.mount() was given, then ui.rebuild()", 3)
end
-- Nothing is written into the spec: a table constructor sizes its hash part to
-- exactly the keys given, so adding type and interactive rehashed most specs --
-- twice for the small ones -- on the build path that peaks the heap. Children
-- come straight off the array part, which tree.create never reads.
local id = tree.create(nil, spec, kind,
spec.on_enter ~= nil or spec.on_exit ~= nil or spec.on_click ~= nil, label)
for _, child in ipairs(spec) do
tree.attach(id, child)
end
enterHandlers[id] = spec.on_enter
exitHandlers[id] = spec.on_exit
clickHandlers[id] = spec.on_click
painters[id] = spec.paint
if spec.press_style == false then
noPressStyle[id] = true
end
if spec.scrollX or spec.scrollY then
scrollNodes[id] = { x = spec.scrollX == true, y = spec.scrollY == true }
scrollState[id] = { sx = 0, sy = 0, vx = 0, vy = 0 }
end
return id
end
---@param spec UiSpec
---@return NodeId
function ui.box(spec)
return build(spec, "box")
end
---Side length for a grid of square cards that fits the frame, and the column count that
---produced it. Landscape gets a wider grid, so the same screen reflows on rotation.
---@param count integer Cards to place.
---@param pad integer Padding outside the grid.
---@param gap integer Gap between cards.
---@param reserve? integer Height to leave free below the grid.
---@return integer side
---@return integer columns
function ui.cardSide(count, pad, gap, reserve)
local width, height = ui.frame()
local columns = width >= height and 3 or 2
local rows = math.ceil(count / columns)
local byWidth = (width - 2 * pad - (columns - 1) * gap) // columns
local byHeight = (height - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows
return math.min(byWidth, byHeight), columns
end
---How much of the panel chrome took before the app was built. Set by whatever mounts the
---tree, because layout has not run yet when an app sizes itself.
---@param px integer
function ui.setInset(px)
inset = px
end
---The box the app is built into, which is the panel minus the chrome above it.
---@return integer w
---@return integer h
function ui.frame()
return screen.getWidth(), screen.getHeight() - inset
end
---@param spec UiSpec
---@return NodeId
function ui.spacer(spec)
-- Passed through rather than copied into a fresh {w, h}: tree.create reads what it
-- needs and ignores the rest.
return build(spec or EMPTY, "box")
end
---@param text string
---@param spec? UiSpec
---@return NodeId
function ui.text(text, spec)
-- Writes nothing: the label rides as an argument, so a two-key text spec stays
-- two keys instead of rehashing to four.
return build(spec or EMPTY, "text", text)
end
---@param text string
---@param spec? UiSpec
---@return NodeId
function ui.label(text, spec)
spec = spec or EMPTY
-- Sized by the intrinsic width tree.create measures from the same text, font and
-- style, so this measures only when the text has to be truncated. Writing w and h
-- here measured the string a second time and grew the spec by two keys, which on a
-- one-key spec is two rehashes.
if spec.fit then
local font = spec.font or screen.FONT_UI
local style = spec.style or screen.STYLE_NORMAL
if screen.getTextWidth(font, text, style) > spec.fit then
while #text > 1 and screen.getTextWidth(font, text .. "~", style) > spec.fit do
text = text:sub(1, -2)
end
text = text .. "~"
end
end
return ui.text(text, spec)
end
---@param spec UiSpec
---@return NodeId
function ui.button(spec)
spec = spec or EMPTY
local label, font = spec.label, spec.font
local id = build(spec, "button")
if label then
-- The button's own spec.label is not read by tree.create, so it needs no
-- clearing; the child text node carries the label instead.
tree.create(id, font and { font = font } or EMPTY, "text", false, label)
end
return id
end
---@param spec UiSpec
---@return NodeId
function ui.custom(spec)
return build(spec, "custom")
end
---@param id NodeId
---@param text string
function ui.setText(id, text)
if tree.getLabel(id) == text then
return
end
tree.setLabel(id, text)
tree.invalidate(id)
end
---@param id NodeId
function ui.invalidate(id)
tree.invalidate(id)
end
---Pans a scrollable node, clamped to its content. The node is marked dirty, so the next
---ui.draw() repaints it -- there is nothing for the app to draw and no handle to refresh,
---which is the whole point of scrolling in the tree rather than in a painter.
---@param id NodeId
---@param x integer
---@param y integer
function ui.setScroll(id, x, y)
tree.setScroll(id, x, y)
end
---@param id NodeId
---@return integer x, integer y
function ui.getScroll(id)
return tree.getScroll(id)
end
---How far each axis can pan. Zero on an axis whose content already fits.
---@param id NodeId
---@return integer maxX, integer maxY
function ui.getScrollRange(id)
return tree.getScrollRange(id)
end
---@param spec UiConfirmSpec
---@return NodeId
function ui.confirm(spec)
local card = {
w = spec.w or 0.85,
pad = 16,
gap = 12,
background = spec.background or ui.theme.background,
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_click = spec.on_cancel }
end
buttons[#buttons + 1] = ui.button { label = spec.ok or "ok", on_click = spec.on_ok }
card[#card + 1] = ui.box(buttons)
return ui.box {
at = { x = 0, y = 0 },
w = "fill",
h = "fill",
-- A full-bleed layer painted last: hit-testing returns the topmost node, so taps on the
-- dim area land here and cannot reach the content beneath. on_outside handles them; with
-- none, they die at the root -- which is what makes the dialog modal.
align = "center",
justify = "center",
on_click = spec.on_outside,
ui.box(card),
}
end
function ui.reset()
tree.reset()
clearState()
laidOut = false
root, responder, insideResponder, confirming = nil, nil, nil, nil
scrollNodes, scrollState, flinging = {}, {}, {}
panning, scrollAncestor, dragged = nil, nil, false
end
applyPalette = function(root)
-- The root is the panel background, not a card: no border, so it takes the fast fillRect
-- path rather than the per-pixel roundRect one. Radius stays so cards inherit it.
tree.setStyle(root, {
color = ui.theme.color,
background = ui.theme.background,
face = ui.theme.face,
pressedFace = ui.theme.pressedFace,
pressedColor = ui.theme.pressedColor,
focusColor = ui.theme.focusColor,
radius = ui.theme.radius,
font = screen.FONT_UI,
})
end
---Registers the function that builds the whole tree and shows what it returns.
---@param fn fun(): NodeId
function ui.mount(fn)
builder = fn
ui.rebuild()
end
---Rebuilds the tree from scratch and repaints. Screens are not retained, so this
---is how a screen changes, a rotation is answered and a dialog opens.
function ui.rebuild()
ui.reset()
root = builder()
tree.setSize(root, "fill", "fill")
applyPalette(root)
local ok, err = tree.layout(root, 0, 0, screen.getWidth(), screen.getHeight())
if not ok then
error(err, 2)
end
tree.dropScratch()
laidOut = true
-- A build allocates a spec table per node and drops them all here, and the painter is
-- the very next thing to want a large contiguous block for its band. Collecting before
-- the repaint rather than after it sizes the band against the heap that exists, not one
-- still holding a screen's worth of dead spec tables -- a C++ allocation gets no
-- emergency collection the way a failed Lua one does. It costs a few milliseconds on a
-- screen change nobody can see, and leaves the heap in a known state instead of one that
-- depends on when the incremental GC last ran.
collectgarbage()
screen.clear(ui.theme.background)
tree.draw(root)
end
-- Pushes a node's float offset into the tree, then snaps our copy back to whatever the
-- clamp allowed -- without this the offset runs past the end while the content sits still
-- and the list ignores the first part of the drag back.
local function applyScroll(id, st)
ui.setScroll(id, math.floor(st.sx), math.floor(st.sy))
local cx, cy = ui.getScroll(id)
if cx and cx ~= math.floor(st.sx) then
st.sx, st.vx = cx, 0
end
if cy and cy ~= math.floor(st.sy) then
st.sy, st.vy = cy, 0
end
end
-- Free settle: velocity decays to rest, applied as DECAY^seconds so the glide lasts the
-- same wall time whatever the frame rate. A paged box would instead ease toward the nearest
-- page boundary here -- same gesture, different ending -- so this is the one seam paging adds.
local function settleFree(st, seconds)
st.sx, st.sy = st.sx + st.vx * seconds, st.sy + st.vy * seconds
local keep = SCROLL_DECAY ^ seconds
st.vx, st.vy = st.vx * keep, st.vy * keep
if math.abs(st.vx) < SCROLL_STOP then
st.vx = 0
end
if math.abs(st.vy) < SCROLL_STOP then
st.vy = 0
end
end
-- One GC step a frame, because the collector's default pace is the painter's problem: a
-- band buffer needs a contiguous block, and letting the heap double before a cycle lets
-- garbage take the block the band was going to get. Stepping keeps the sawtooth shallow
-- enough that beginBuffer() keeps succeeding instead of falling back to the panel.
---@param deltaMs? integer Elapsed frame time, used to advance any active pan or flick.
function ui.draw(deltaMs)
local seconds = (deltaMs or 0) / 1000
if seconds > 0 then
if panning then
local st = scrollState[panning]
st.sx, st.sy = st.sx + pendingX, st.sy + pendingY
-- Velocity is measured over the frame the motion arrived in, so a finger that paused
-- before lifting reports zero and does not flick.
st.vx, st.vy = pendingX / seconds, pendingY / seconds
pendingX, pendingY = 0, 0
applyScroll(panning, st)
end
for id in pairs(flinging) do
local st = scrollState[id]
settleFree(st, seconds)
applyScroll(id, st)
if st.vx == 0 and st.vy == 0 then
flinging[id] = nil
end
end
end
if root then
tree.draw(root)
end
collectgarbage "step"
end
-- The nearest ancestor (or self) that pans, or nil. A drag on a child button scrolls the
-- list it sits in, which is why the tap has to yield to the pan rather than the reverse.
local function scrollableAncestor(id)
while id do
if scrollNodes[id] then
return id
end
id = tree.getParent(id)
end
end
-- The nearest ancestor (or self) that handles a press, or nil. tree.hit returns the deepest
-- node by geometry -- a button's text label, say -- so dispatch bubbles up to whoever owns
-- the on_click/on_enter/on_exit, the way a responder chain does.
local function handlerAncestor(id)
while id do
if clickHandlers[id] or enterHandlers[id] or exitHandlers[id] then
return id
end
id = tree.getParent(id)
end
end
local function inside(id, x, y)
local rx, ry, rw, rh = tree.getRect(id)
return x >= rx and x < rx + rw and y >= ry and y < ry + rh
end
local function enter(id, x, y)
if not noPressStyle[id] then
tree.setPressed(id, true)
end
local handler = enterHandlers[id]
if handler then
handler(id, x, y)
end
end
local function exit(id, x, y)
if not noPressStyle[id] then
tree.setPressed(id, false)
end
local handler = exitHandlers[id]
if handler then
handler(id, x, y)
end
end
---@param x integer
---@param y integer
---@return boolean handled
function ui.down(x, y)
local focused = tree.getFocus()
if focused then
tree.setFocus(nil)
local handler = exitHandlers[focused]
if handler then
handler(focused)
end
end
local hitNode = root and tree.hit(root, x, y)
if not hitNode then
return false
end
responder, scrollAncestor = handlerAncestor(hitNode), scrollableAncestor(hitNode)
insideResponder, dragged, panning = responder ~= nil, false, nil
downX, downY, lastX, lastY = x, y, x, y
pendingX, pendingY = 0, 0
if scrollAncestor then
-- A finger down catches an in-progress glide, the way every touch UI does.
flinging[scrollAncestor] = nil
local st = scrollState[scrollAncestor]
st.vx, st.vy = 0, 0
end
if responder then
enter(responder, x, y)
end
return responder ~= nil or scrollAncestor ~= nil
end
---@param x integer
---@param y integer
---@return boolean handled
function ui.move(x, y)
if not responder and not scrollAncestor then
return false
end
if scrollAncestor then
local flags = scrollNodes[scrollAncestor]
if flags.x then
pendingX = pendingX + (lastX - x)
end
if flags.y then
pendingY = pendingY + (lastY - y)
end
lastX, lastY = x, y
if not dragged then
local past = (flags.x and math.abs(x - downX) > SCROLL_SLOP)
or (flags.y and math.abs(y - downY) > SCROLL_SLOP)
if past then
dragged, panning = true, scrollAncestor
-- The press became a scroll: drop the responder's feedback and keep it from clicking.
if responder and insideResponder then
exit(responder, x, y)
end
insideResponder = false
end
end
if dragged then
return true
end
end
if responder then
local isInside = inside(responder, x, y)
if isInside ~= insideResponder then
insideResponder = isInside
if isInside then
enter(responder, x, y)
else
exit(responder, x, y)
end
end
end
return true
end
---@param x integer
---@param y integer
---@return boolean handled
function ui.up(x, y)
if not responder and not scrollAncestor then
return false
end
if dragged then
-- The gesture was a pan: hand any velocity the last frames built to draw(), which coasts
-- and clamps it. A drag that ended still carries zero velocity, so it simply stops.
if panning then
flinging[panning] = true
end
responder, insideResponder, scrollAncestor, dragged, panning = nil, nil, nil, false, nil
return true
end
local target, wasActive = responder, insideResponder
responder, insideResponder, scrollAncestor = nil, nil, nil
if target then
if wasActive then
exit(target, x, y)
end
if inside(target, x, y) then
local handler = clickHandlers[target]
if handler then
handler(target, x, y)
end
end
end
return true
end
local DIRECTIONS = { up = true, down = true, left = true, right = true }
local function focusFirst()
local focused = tree.focusFirst(root)
if focused then
local handler = enterHandlers[focused]
if handler then
handler(focused)
end
end
return focused
end
---@param name string Button name; directions and confirm are handled.
---@param pressed boolean
---@return boolean handled
function ui.buttonPress(name, pressed)
if type(pressed) ~= "boolean" then
error("button state must be boolean", 2)
end
if DIRECTIONS[name] then
if not pressed then
return true
end
local previous = tree.getFocus()
if not previous then
focusFirst()
return true
end
local focused = tree.moveFocus(root, name)
if focused ~= previous then
local leave = exitHandlers[previous]
if leave then
leave(previous)
end
local arrive = enterHandlers[focused]
if arrive then
arrive(focused)
end
end
return true
end
if name ~= "confirm" then
return false
end
local focused = tree.getFocus() or focusFirst()
if not focused then
return false
end
if pressed then
tree.setPressed(focused, true)
confirming = focused
else
local target = confirming
confirming = nil
if target then
tree.setPressed(target, false)
local handler = clickHandlers[target]
if handler then
handler(target)
end
end
end
return true
end
return ui