feat(ui): one mounted tree instead of a screen object per build

Chrome and the app now share a tree, so a screen is no longer something an
app constructs and holds: ui.mount() takes the function that builds the
whole thing and ui.rebuild() runs it again. Building a node after layout
is refused rather than silently resetting the arena under the panel.
This commit is contained in:
2026-08-04 20:53:15 -04:00
parent e3153f57c5
commit 3f26c4ff10
3 changed files with 96 additions and 82 deletions
+51 -51
View File
@@ -54,7 +54,9 @@ local enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {}
local pressStyles = {}
local laidOut = false
local themeName
local activeScreen
-- 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 applyPalette
local function mix(a, b, amount)
@@ -117,10 +119,10 @@ function ui.setTheme(name)
return nil, err
end
loadTheme(name)
if activeScreen then
applyPalette(activeScreen.root)
if root then
applyPalette(root)
gui.clear(ui.theme.background)
node.invalidate(activeScreen.root)
node.invalidate(root)
end
return true
end
@@ -169,8 +171,10 @@ 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
ui.reset()
error("build nodes from the function ui.mount() was given, then ui.rebuild()", 3)
end
local children = {}
@@ -326,13 +330,9 @@ function ui.reset()
node.reset()
clearState()
laidOut = false
activeScreen = nil
root, captured, insideCaptured, confirming = nil, nil, nil, nil
end
---@class UiScreen
local Screen = {}
Screen.__index = Screen
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.
@@ -348,33 +348,34 @@ applyPalette = function(root)
})
end
---@param root NodeId
---@param style? NodeStyle
---@return UiScreen
function ui.screen(root, style)
node.setSize(root, "fill", "fill")
applyPalette(root)
if style then
node.setStyle(root, style)
end
local screen = setmetatable({ root = root }, Screen)
activeScreen = screen
screen:relayout()
return screen
---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
function Screen:relayout()
local ok, err = node.layout(self.root, 0, 0, gui.getWidth(), gui.getHeight())
---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 Screen:draw()
node.draw(self.root)
function ui.draw()
if root then
node.draw(root)
end
end
local function inside(id, x, y)
@@ -405,7 +406,7 @@ end
---@param x integer
---@param y integer
---@return boolean handled
function Screen:down(x, y)
function ui.down(x, y)
local focused = node.getFocus()
if focused then
node.setFocus(nil)
@@ -415,11 +416,11 @@ function Screen:down(x, y)
end
end
local target = node.hit(self.root, x, y)
local target = root and node.hit(root, x, y)
if not target then
return false
end
self.captured, self.inside = target, true
captured, insideCaptured = target, true
enter(target, x, y)
return true
end
@@ -427,18 +428,17 @@ end
---@param x integer
---@param y integer
---@return boolean handled
function Screen:move(x, y)
local target = self.captured
if not target then
function ui.move(x, y)
if not captured then
return false
end
local isInside = inside(target, x, y)
if isInside ~= self.inside then
self.inside = isInside
local isInside = inside(captured, x, y)
if isInside ~= insideCaptured then
insideCaptured = isInside
if isInside then
enter(target, x, y)
enter(captured, x, y)
else
exit(target, x, y)
exit(captured, x, y)
end
end
return true
@@ -447,15 +447,15 @@ end
---@param x integer
---@param y integer
---@return boolean handled
function Screen:up(x, y)
local target = self.captured
function ui.up(x, y)
local target = captured
if not target then
return false
end
local wasActive = self.inside
local wasActive = insideCaptured
local releasedInside = inside(target, x, y)
local handler = releasedInside and clickHandlers[target] or nil
self.captured, self.inside = nil, nil
captured, insideCaptured = nil, nil
if wasActive then
exit(target, x, y)
end
@@ -467,8 +467,8 @@ end
local DIRECTIONS = { up = true, down = true, left = true, right = true }
local function focusFirst(screen)
local focused = node.focusFirst(screen.root)
local function focusFirst()
local focused = node.focusFirst(root)
if focused then
local handler = enterHandlers[focused]
if handler then
@@ -481,7 +481,7 @@ end
---@param name string Button name; directions and confirm are handled.
---@param pressed boolean
---@return boolean handled
function Screen:button(name, pressed)
function ui.buttonPress(name, pressed)
if type(pressed) ~= "boolean" then
error("button state must be boolean", 2)
end
@@ -492,10 +492,10 @@ function Screen:button(name, pressed)
end
local previous = node.getFocus()
if not previous then
focusFirst(self)
focusFirst()
return true
end
local focused = node.moveFocus(self.root, name)
local focused = node.moveFocus(root, name)
if focused ~= previous then
local leave = exitHandlers[previous]
if leave then
@@ -512,16 +512,16 @@ function Screen:button(name, pressed)
if name ~= "confirm" then
return false
end
local focused = node.getFocus() or focusFirst(self)
local focused = node.getFocus() or focusFirst()
if not focused then
return false
end
if pressed then
node.setPressed(focused, true)
self.confirming = focused
confirming = focused
else
local target = self.confirming
self.confirming = nil
local target = confirming
confirming = nil
if target then
node.setPressed(target, false)
local handler = clickHandlers[target]
+44 -30
View File
@@ -186,27 +186,30 @@ local function handler(name)
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 })
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(screen:down(10, 10))
assert(ui.down(10, 10))
assert(node.isPressed(first))
assert(screen:move(95, 10))
assert(ui.move(95, 10))
assert(not node.isPressed(first))
assert(screen:move(10, 10))
assert(ui.move(10, 10))
assert(node.isPressed(first))
assert(screen:up(10, 10))
assert(ui.up(10, 10))
assert(not node.isPressed(first))
local expectedTouch = { "enter", "exit", "enter", "exit", "click" }
@@ -218,16 +221,16 @@ for index, name in ipairs(expectedTouch) do
end
events = {}
assert(screen:button("right", true))
assert(screen:button("right", false))
assert(ui.buttonPress("right", true))
assert(ui.buttonPress("right", false))
assert(node.getFocus() == first)
assert(screen:button("right", true))
assert(ui.buttonPress("right", true))
assert(node.getFocus() == second)
assert(screen:button("confirm", true))
assert(ui.buttonPress("confirm", true))
assert(node.isPressed(second))
assert(screen:button("confirm", false))
assert(ui.buttonPress("confirm", false))
assert(not node.isPressed(second))
assert(screen:button("back", true) == false)
assert(ui.buttonPress("back", true) == false)
local expectedButtons = {
{ "enter", first },
@@ -246,7 +249,7 @@ assert(ui.setTheme "mono" == true)
assert(ui.getTheme() == "mono" and #invalidated == before + 1)
assert(cleared == ui.theme.background)
screen:draw()
ui.draw()
-- 320x480 portrait: two columns, and the reserve comes off the height budget.
local side, columns = ui.cardSide(5, 12, 8)
@@ -265,21 +268,32 @@ 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 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
board:down(0, 0)
board:up(0, 0)
ui.down(0, 0)
ui.up(0, 0)
assert(#pressedCalls == 0, "a self-painting widget is not styled on press")
target = styled
board:down(0, 0)
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"
File diff suppressed because one or more lines are too long