Files
esp32-lua-api/lua/test/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

306 lines
7.6 KiB
Lua

package.path = "./lua/lib/?.lua;" .. package.path
local files = {}
local cleared, invalidated = nil, {}
fs = {
readFile = function(path, maxBytes)
local value = files[path]
if value and #value > maxBytes then
return nil, "too large"
end
return value, value and nil or "missing"
end,
writeFile = function(path, value)
files[path] = value
return true
end,
}
local savedTheme = "light"
settings = {
getTheme = function()
return savedTheme
end,
setTheme = function(name)
savedTheme = name
return true
end,
}
local frameWidth, frameHeight = 320, 480
gui = {
FONT_SMALL = 0,
FONT_UI = 1,
FONT_BODY = 2,
FONT_LARGE = 3,
STYLE_NORMAL = 0,
STYLE_BOLD = 1,
color = function(r, g, b)
return r * 65536 + g * 256 + b
end,
getWidth = function()
return frameWidth
end,
getHeight = function()
return frameHeight
end,
getTextWidth = function(_, text)
return #text * 6
end,
getFontHeight = function()
return 8
end,
clear = function(color)
cleared = color
end,
}
local nodes, focus, painter = {}, nil, nil
local buttonCount = 0
local function interactiveNodes()
local result = {}
for id, entry in ipairs(nodes) do
if entry.interactive then
result[#result + 1] = id
end
end
return result
end
node = {
reset = function()
nodes, focus, buttonCount = {}, nil, 0
end,
create = function(parent, spec)
local id = #nodes + 1
local x = 0
if spec.type == "button" then
buttonCount = buttonCount + 1
x = (buttonCount - 1) * 100
end
nodes[id] = {
parent = parent,
type = spec.type,
label = spec.label,
interactive = spec.interactive,
rect = { x, 0, 90, 50 },
pressed = false,
style = {},
}
return id
end,
attach = function(parent, child)
nodes[child].parent = parent
end,
setSize = function() end,
setStyle = function(id, style)
for key, value in pairs(style) do
nodes[id].style[key] = value
end
end,
layout = function()
return true
end,
dropScratch = function() end,
hit = function(_, x, y)
for _, id in ipairs(interactiveNodes()) do
local rect = nodes[id].rect
if x >= rect[1] and x < rect[1] + rect[3] and y >= rect[2] and y < rect[2] + rect[4] then
return id
end
end
end,
getRect = function(id)
return table.unpack(nodes[id].rect)
end,
setLabel = function(id, text)
nodes[id].label = text
end,
getLabel = function(id)
return nodes[id].label
end,
invalidate = function(id)
invalidated[#invalidated + 1] = id
end,
setPressed = function(id, pressed)
nodes[id].pressed = pressed
end,
isPressed = function(id)
return nodes[id].pressed
end,
setPainter = function(callback)
painter = callback
end,
draw = function()
if painter then
for id, entry in ipairs(nodes) do
if entry.type == "custom" then
painter(id, table.unpack(entry.rect))
end
end
end
end,
focusFirst = function()
focus = interactiveNodes()[1]
return focus
end,
setFocus = function(id)
focus = id
end,
getFocus = function()
return focus
end,
moveFocus = function(_, direction)
local items, current = interactiveNodes(), nil
for index, id in ipairs(items) do
if id == focus then
current = index
break
end
end
local step = (direction == "right" or direction == "down") and 1 or -1
local nextIndex = current and current + step or 1
if items[nextIndex] then
focus = items[nextIndex]
end
return focus
end,
}
local ui = require "ui"
assert(ui.getTheme() == "light")
assert(table.concat(ui.themeNames(), ",") == "dark,light,mono")
local ok, err = ui.setTheme "missing"
assert(ok == nil and err == "Unknown theme")
assert(ui.setTheme "dark" == true)
assert(savedTheme == "dark" and ui.getTheme() == "dark")
local events = {}
local function handler(name)
return function(id, x, y)
events[#events + 1] = { name, id, x, y }
end
end
local first, second
ui.mount(function()
first = ui.button {
label = "one",
on_enter = handler "enter",
on_exit = handler "exit",
on_click = handler "click",
}
second = ui.button {
label = "two",
on_enter = handler "enter",
on_exit = handler "exit",
on_click = handler "click",
}
return ui.box { row = true, first, second }
end)
assert(ui.down(10, 10))
assert(node.isPressed(first))
assert(ui.move(95, 10))
assert(not node.isPressed(first))
assert(ui.move(10, 10))
assert(node.isPressed(first))
assert(ui.up(10, 10))
assert(not node.isPressed(first))
local expectedTouch = { "enter", "exit", "enter", "exit", "click" }
for index, name in ipairs(expectedTouch) do
local event = events[index]
assert(event and event[1] == name and event[2] == first)
assert(event[3] == 10 or event[3] == 95)
assert(event[4] == 10)
end
events = {}
assert(ui.buttonPress("right", true))
assert(ui.buttonPress("right", false))
assert(node.getFocus() == first)
assert(ui.buttonPress("right", true))
assert(node.getFocus() == second)
assert(ui.buttonPress("confirm", true))
assert(node.isPressed(second))
assert(ui.buttonPress("confirm", false))
assert(not node.isPressed(second))
assert(ui.buttonPress("back", true) == false)
local expectedButtons = {
{ "enter", first },
{ "exit", first },
{ "enter", second },
{ "click", second },
}
for index, expected in ipairs(expectedButtons) do
local event = events[index]
assert(event and event[1] == expected[1] and event[2] == expected[2])
assert(event[3] == nil and event[4] == nil)
end
local before = #invalidated
assert(ui.setTheme "mono" == true)
assert(ui.getTheme() == "mono" and #invalidated == before + 1)
assert(cleared == ui.theme.background)
ui.draw()
-- The inset chrome took comes off the height budget before anything else.
ui.setInset(44)
assert(select(2, ui.frame()) == 436, "the frame is the panel minus the chrome")
assert(select(1, ui.cardSide(5, 12, 8)) == 132, "a grid fits the app's box, not the panel")
ui.setInset(0)
-- 320x480 portrait: two columns, and the reserve comes off the height budget.
local side, columns = ui.cardSide(5, 12, 8)
assert(columns == 2 and side == 144, "width is the binding constraint in portrait")
assert(select(1, ui.cardSide(5, 12, 8, 300)) == 46, "height binds once the reserve is large")
frameWidth, frameHeight = 480, 320
side, columns = ui.cardSide(5, 12, 8)
assert(columns == 3 and side == 144, "landscape uses three columns")
frameWidth, frameHeight = 320, 480
-- A widget painting its own feedback is never given a pressed style, while an ordinary one
-- still is. Which node was hit is geometry, so the test names the target directly.
ui.reset()
local pressedCalls = {}
node.setPressed = function(_, on)
pressedCalls[#pressedCalls + 1] = on
end
local own, styled
ui.mount(function()
own = ui.custom { h = 20, press_style = false, on_click = function() end }
styled = ui.button { h = 20, label = "ok", on_click = function() end }
return ui.box { own, styled }
end)
local target
node.hit = function()
return target
end
target = own
ui.down(0, 0)
ui.up(0, 0)
assert(#pressedCalls == 0, "a self-painting widget is not styled on press")
target = styled
ui.down(0, 0)
assert(pressedCalls[1] == true, "an ordinary widget still gets its pressed style")
-- Building outside a rebuild would reset the arena under the screen on the panel.
local built = pcall(ui.button, { label = "stray" })
assert(not built, "a node built after layout is refused")
local rebuilt = false
ui.rebuild()
rebuilt = true
assert(rebuilt and ui.down(0, 0), "a rebuild replaces the screen and keeps dispatch live")
print "ok"