Files
esp32-lua-api/lua/test/ui.lua
T
evan 3416f54c83 feat(ui): widen cards to a 2/3 column grid and add a disabled role
Two columns portrait and three landscape read better than 3/4 on a 320x480
panel. The new disabled palette role sits between face and muted, for a
control that is present but inactive.
2026-08-04 13:10:05 -04:00

224 lines
6.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 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(files["/.lua/theme"] == "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 = ui.button{
label = "one",
on_enter = handler("enter"),
on_exit = handler("exit"),
on_click = handler("click"),
}
local second = ui.button{
label = "two",
on_enter = handler("enter"),
on_exit = handler("exit"),
on_click = handler("click"),
}
local screen = ui.screen(ui.box{row = true, first, second})
assert(screen:down(10, 10))
assert(node.isPressed(first))
assert(screen:move(95, 10))
assert(not node.isPressed(first))
assert(screen:move(10, 10))
assert(node.isPressed(first))
assert(screen: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(screen:button("right", true))
assert(screen:button("right", false))
assert(node.getFocus() == first)
assert(screen:button("right", true))
assert(node.getFocus() == second)
assert(screen:button("confirm", true))
assert(node.isPressed(second))
assert(screen:button("confirm", false))
assert(not node.isPressed(second))
assert(screen:button("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)
screen:draw()
-- 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 = ui.custom{h = 20, press_style = false, on_click = function() end}
local styled = ui.button{h = 20, label = "ok", on_click = function() end}
local board = ui.screen(ui.box{own, styled})
local target
node.hit = function() return target end
target = own
board:down(0, 0)
board:up(0, 0)
assert(#pressedCalls == 0, "a self-painting widget is not styled on press")
target = styled
board:down(0, 0)
assert(pressedCalls[1] == true, "an ordinary widget still gets its pressed style")
print("ok")