Files
esp32-lua-api/lua/lib/ui.lua
T
evan 4b89b27947 feat(ui): size a grid to the app's box, not the panel
Chrome takes the top of the panel before an app builds anything, and layout
has not run yet when it does, so ui.frame() reports what the mount left it.
2026-08-04 21:15:21 -04:00

553 lines
14 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 capture? boolean
---@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)
---@field press_style? boolean False for a widget that paints its own press feedback.
---@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, captured, insideCaptured, confirming
local inset = 0
local applyPalette
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 gui.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 = settings.setTheme(name)
if not ok then
return nil, err
end
loadTheme(name)
if root then
applyPalette(root)
gui.clear(ui.theme.background)
node.invalidate(root)
end
return true
end
loadTheme(settings.getTheme())
local function clearState()
enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {}
pressStyles = {}
end
node.setPainter(function(id, x, y, w, h)
local painter = painters[id]
if painter then
painter(id, x, y, w, h)
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
node.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 = node.create(nil, spec)
for _, child in ipairs(children) do
node.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
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 gui.getWidth(), gui.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 gui.FONT_UI, spec.style or gui.STYLE_NORMAL
if spec.fit and gui.getTextWidth(font, text, style) > spec.fit then
while #text > 1 and gui.getTextWidth(font, text .. "~", style) > spec.fit do
text = text:sub(1, -2)
end
text = text .. "~"
end
spec.w = gui.getTextWidth(font, text, style)
spec.h = gui.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
node.create(id, { type = "text", label = label, font = font or gui.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 node.getLabel(id) == text then
return
end
node.setLabel(id, text)
node.invalidate(id)
end
---@param id NodeId
function ui.invalidate(id)
node.invalidate(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",
capture = true,
align = "center",
justify = "center",
on_click = spec.on_outside,
ui.box(card),
}
end
function ui.reset()
node.reset()
clearState()
laidOut = false
root, captured, insideCaptured, confirming = nil, nil, nil, nil
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.
node.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 = gui.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()
node.setSize(root, "fill", "fill")
applyPalette(root)
local ok, err = node.layout(root, 0, 0, gui.getWidth(), gui.getHeight())
if not ok then
error(err, 2)
end
node.dropScratch()
laidOut = true
gui.clear(ui.theme.background)
node.draw(root)
end
function ui.draw()
if root then
node.draw(root)
end
end
local function inside(id, x, y)
local rx, ry, rw, rh = node.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
node.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
node.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 = node.getFocus()
if focused then
node.setFocus(nil)
local handler = exitHandlers[focused]
if handler then
handler(focused)
end
end
local target = root and node.hit(root, x, y)
if not target then
return false
end
captured, insideCaptured = target, true
enter(target, x, y)
return true
end
---@param x integer
---@param y integer
---@return boolean handled
function ui.move(x, y)
if not captured then
return false
end
local isInside = inside(captured, x, y)
if isInside ~= insideCaptured then
insideCaptured = isInside
if isInside then
enter(captured, x, y)
else
exit(captured, x, y)
end
end
return true
end
---@param x integer
---@param y integer
---@return boolean handled
function ui.up(x, y)
local target = captured
if not target then
return false
end
local wasActive = insideCaptured
local releasedInside = inside(target, x, y)
local handler = releasedInside and clickHandlers[target] or nil
captured, insideCaptured = nil, nil
if wasActive then
exit(target, x, y)
end
if handler then
handler(target, x, y)
end
return true
end
local DIRECTIONS = { up = true, down = true, left = true, right = true }
local function focusFirst()
local focused = node.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 = node.getFocus()
if not previous then
focusFirst()
return true
end
local focused = node.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 = node.getFocus() or focusFirst()
if not focused then
return false
end
if pressed then
node.setPressed(focused, true)
confirming = focused
else
local target = confirming
confirming = nil
if target then
node.setPressed(target, false)
local handler = clickHandlers[target]
if handler then
handler(target)
end
end
end
return true
end
return ui