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]