Files
esp32-lua-api/lua/lib/ui.lua
T
evan 2046938d32 feat(ui): tree-owned scroll gestures and geometry hit-testing
Drag, flick and tap-vs-scroll now live on the scrollX/scrollY flag in
ui.lua, so any scroll box pans with no app code. tree.hit returns the
deepest node by geometry and dispatch bubbles to the nearest handler,
dropping the now-unused CAPTURE flag. keyboard moves in as ui.keyboard,
and embed compiles nested lib dirs to dotted module names.
2026-08-06 17:07:56 -04:00

737 lines
21 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 style? GuiTextStyle
---@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.
local pressStyles = {}
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 = {}, {}, {}, {}
pressStyles = {}
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)
local STYLE_KEYS = {
"color",
"fill",
"border",
"face",
"pressedFace",
"pressedColor",
"focusColor",
"radius",
"font",
"textStyle",
}
local function applyStyle(id, spec)
local style, hasStyle = {}, false
for _, key in ipairs(STYLE_KEYS) do
if spec[key] ~= nil then
style[key], hasStyle = spec[key], true
end
end
if spec.background ~= nil then
style.background, style.fill, hasStyle = spec.background, spec.background, true
end
if hasStyle then
tree.setStyle(id, style)
end
end
local function build(spec, kind)
spec = spec or {}
-- 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
local children = {}
for index, child in ipairs(spec) do
children[index] = child
spec[index] = nil
end
spec.type = kind
spec.interactive = spec.on_enter ~= nil or spec.on_exit ~= nil or spec.on_click ~= nil
local id = tree.create(nil, spec)
for _, child in ipairs(children) do
tree.attach(id, child)
end
applyStyle(id, spec)
enterHandlers[id] = spec.on_enter
exitHandlers[id] = spec.on_exit
clickHandlers[id] = spec.on_click
painters[id] = spec.paint
pressStyles[id] = spec.press_style ~= false
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)
spec = spec or {}
return build({ w = spec.w, h = spec.h }, "box")
end
---@param text string
---@param spec? UiSpec
---@return NodeId
function ui.text(text, spec)
spec = spec or {}
spec.label = text
spec.textStyle, spec.style = spec.style, nil
return build(spec, "text")
end
---@param text string
---@param spec? UiSpec
---@return NodeId
function ui.label(text, spec)
spec = spec or {}
local font, style = spec.font or screen.FONT_UI, spec.style or screen.STYLE_NORMAL
if spec.fit and 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
spec.w = screen.getTextWidth(font, text, style)
spec.h = screen.getFontHeight(font, style)
spec.font, spec.fit = font, nil
return ui.text(text, spec)
end
---@param spec UiSpec
---@return NodeId
function ui.button(spec)
spec = spec or {}
spec.pad = spec.pad or 8
spec.align = spec.align or "center"
local label, font = spec.label, spec.font
spec.label = nil
local id = build(spec, "button")
if label then
tree.create(id, { type = "text", label = label, font = font or screen.FONT_UI })
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
screen.clear(ui.theme.background)
tree.draw(root)
-- A build allocates a spec table per node and drops them all here, and the next thing an
-- app does may be the one that needs a contiguous WiFi buffer. Collecting now 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()
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 pressStyles[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 pressStyles[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