feat(ui)!: move the widget tree into C++

A node was a Lua table of ~625 bytes, of which 21 keys pushed it over a
power-of-two hash boundary and eight were style copies inheritance had
splattered down from its parent. A 400 node screen cost ~250 KB and could not
coexist with wifi's buffers.

The tree now lives in src/ui/layout.h as a 16 byte struct in a flat arena, and
splits by lifetime: Node holds what hit testing and repainting need forever,
Spec holds what only measure/place read and is dropped when layout ends. Style
is sparse and resolved by walking parents, so a node naming no colours costs
nothing. Re-layout rebuilds from Lua rather than retaining the inputs.

    401 nodes:  8218 B steady, 21050 B peak
           Lua: ~250000 B steady

sdcard/lib/ui.lua stays the toolkit and keeps every constructor signature, but
returns integer handles: 627 lines to 374. Composition, the palette and custom
painters are still Lua on the SD card; only primitives now need a reflash.

BREAKING CHANGE: ui constructors return handles, not tables. Use
ui.setText(id, text) and keep per-node app data in a table keyed by id.
This commit is contained in:
2026-08-02 18:48:48 -04:00
parent 932d41aeeb
commit f7c5cc09ba
21 changed files with 2112 additions and 795 deletions
+70 -99
View File
@@ -1,119 +1,90 @@
-- Run: lua test/keyboard.lua
-- The keyboard's geometry: which key covers a point, and where each key is drawn. This
-- is the part that decides what a tap enters, and it is pure, so it is asserted here.
-- Painting, press highlighting and the 80 ms hold need the panel and the node tree, so
-- they are emulator behaviour.
package.path = "sdcard/lib/?.lua;test/?.lua;" .. package.path
local device = require("fake_device").install()
local ui = require("ui")
require("fake_device").install()
local keyboard = require("keyboard")
local changes, submitted = {}, nil
local board = keyboard.new{
on_change = function(value) changes[#changes + 1] = value end,
on_submit = function(value) submitted = value end,
}
local screen = ui.screen(ui.box{pad = 8, board})
-- The board as ui.screen would place it: full width inside 8px of padding.
local RECT = {x = 8, y = 8, w = 304, h = 4 * 30 + 3 * 5}
local rounds = {}
gui.roundRect = function(x, y, w, h, radius, bg, top, bottom, border)
rounds[#rounds + 1] = {x = x, y = y, w = w, top = top, bottom = bottom, border = border}
local function keys(page)
local found = {}
keyboard.eachKey(page, RECT, function(id, label, x, y, width)
found[#found + 1] = {id = id, label = label, x = x, y = y, w = width}
end)
return found
end
local function draw()
rounds = {}
screen:draw()
local function centreOf(page, label)
for _, key in ipairs(keys(page)) do
if key.label == label then return key.x + key.w // 2, key.y + 15 end
end
error("no key labelled " .. label)
end
local function tap(x, y)
screen:down(x, y)
screen:up(x, y)
device.now = device.now + 100
screen:draw()
screen:draw()
local function entered(page, label)
local x, y = centreOf(page, label)
local id, action, char = keyboard.keyAt(page, RECT, x, y)
return id, action, char
end
local row1X = board.rect.x + (board.rect.w - 296) // 2 + 13
local row2X = board.rect.x + (board.rect.w - 266) // 2 + 13
local row3Y = board.rect.y + 85
local bottomY = board.rect.y + 120
local qx, qy = row1X, board.rect.y + 15
-- Rows are staggered and centred like a physical keyboard: ten keys, then nine, then
-- seven between two wide keys.
local lower = keys("lower")
assert(lower[1].label == "q" and lower[10].label == "p", "top row is qwertyuiop")
assert(lower[1].x == RECT.x + 4, "top row was not centred, x=" .. lower[1].x)
assert(lower[11].label == "a" and lower[11].x == RECT.x + 19,
"home row was not centred, x=" .. lower[11].x)
-- Rows are staggered and centered like a physical keyboard.
draw()
assert(rounds[1].x == board.rect.x + 4, "top row was not centered")
assert(rounds[11].x == board.rect.x + 19, "home row was not centered")
-- Every key that gets painted must be findable by a tap at its centre, or a key exists
-- that cannot be pressed.
for _, page in ipairs({"lower", "upper", "numbers", "symbols"}) do
for _, key in ipairs(keys(page)) do
local id = keyboard.keyAt(page, RECT, key.x + key.w // 2, key.y + 15)
assert(id == key.id, string.format("%s key %q at %d,%d hit %s", page, key.label, key.x,
key.y, tostring(id)))
end
end
-- Only the touched key uses the pressed palette.
local normalQ = rounds[1]
screen:down(qx, qy)
draw()
assert(#rounds == 1, "pressing q repainted " .. #rounds .. " keys")
assert(rounds[1].top ~= normalQ.top or rounds[1].bottom ~= normalQ.bottom,
"pressed q kept its normal gradient")
screen:up(qx, qy)
assert(changes[#changes] == "q", "q was not entered")
device.now = 80
draw()
assert(#rounds == 1, "releasing q repainted " .. #rounds .. " keys")
-- Gaps between keys are dead, so a tap that lands between two of them enters neither.
assert(keyboard.keyAt("lower", RECT, lower[1].x + lower[1].w + 1, lower[1].y + 15) == nil,
"the gap between q and w entered a key")
assert(keyboard.keyAt("lower", RECT, lower[1].x, RECT.y + 30 + 2) == nil,
"the gap between rows entered a key")
assert(keyboard.keyAt("lower", RECT, lower[1].x, RECT.y - 1) == nil, "above the board hit a key")
-- Starting another tap during the hold clears the old key without redrawing the board.
local rapidBoard = keyboard.new{}
local rapidScreen = ui.screen(ui.box{pad = 8, rapidBoard})
rounds = {}
rapidScreen:draw()
local rapidNormal = rounds[1].top
local rapidQx, rapidY = rapidBoard.rect.x + 17, rapidBoard.rect.y + 15
local rapidWx = rapidQx + 30
rapidScreen:down(rapidQx, rapidY)
rounds = {}
rapidScreen:draw()
rapidScreen:up(rapidQx, rapidY)
device.now = 90
rapidScreen:down(rapidWx, rapidY)
rounds = {}
rapidScreen:draw()
assert(#rounds == 2, "overlapping taps repainted " .. #rounds .. " keys")
local states = {}
for _, key in ipairs(rounds) do states[key.x] = key.top end
assert(states[rapidQx - 13] == rapidNormal, "previous key stayed pressed")
assert(states[rapidWx - 13] ~= rapidNormal, "new key was not pressed")
rapidScreen:up(rapidWx, rapidY)
device.now = 170
rounds = {}
rapidScreen:draw()
assert(#rounds == 1 and rounds[1].top == rapidNormal, "new key stayed pressed")
-- Characters report themselves; the rest report what they do.
local _, action, char = entered("lower", "q")
assert(action == "char" and char == "q", "q did not enter itself")
local _, upperAction, upperChar = entered("upper", "Q")
assert(upperAction == "char" and upperChar == "Q", "the upper page did not enter Q")
-- Sliding onto another key cancels instead of entering the release position.
screen:draw()
screen:draw()
screen:down(qx, qy)
screen:up(qx + 30, qy)
assert(#changes == 1, "sliding from q to w entered a key")
assert(select(2, entered("lower", "shift")) == "shift", "shift key")
assert(select(2, entered("lower", "<-")) == "backspace", "backspace key")
assert(select(2, entered("lower", "123")) == "mode", "mode key")
assert(select(2, entered("lower", "space")) == "space", "space key")
assert(select(2, entered("lower", "OK")) == "submit", "submit key")
-- Shift toggles case without replacing the keyboard.
local shiftX = board.rect.x + (board.rect.w - 294) // 2 + 20
tap(shiftX, row3Y)
tap(qx, qy)
assert(changes[#changes] == "qQ", "shift did not enter uppercase Q")
tap(shiftX, row3Y)
-- The number page swaps shift for the symbols toggle, which is the only key whose
-- meaning changes with the page it is on.
assert(select(2, entered("numbers", "#+=")) == "symbols", "numbers page toggles symbols")
assert(select(2, entered("symbols", "123")) == "symbols", "symbols page toggles back")
assert(select(2, entered("numbers", "ABC")) == "mode", "numbers page returns to letters")
-- 123 opens numbers, #+= opens symbols, and backspace preserves the value.
local modeX = board.rect.x + (board.rect.w - 294) // 2 + 27
tap(modeX, bottomY)
tap(qx, qy)
assert(changes[#changes] == "qQ1", "number page did not enter 1")
local symbolX = board.rect.x + (board.rect.w - 234) // 2 + 20
tap(symbolX, row3Y)
tap(qx, qy)
assert(changes[#changes] == "qQ1[", "symbol page did not enter [")
local backspaceX = board.rect.x + (board.rect.w - 234) // 2 + 214
tap(backspaceX, row3Y)
assert(changes[#changes] == "qQ1", "backspace did not remove the symbol")
-- Bottom row is ABC, a wide spacebar, then OK.
tap(modeX, bottomY)
local spaceX = board.rect.x + (board.rect.w - 294) // 2 + 145
tap(spaceX, bottomY)
local okX = board.rect.x + (board.rect.w - 294) // 2 + 265
tap(okX, bottomY)
assert(submitted == "qQ1 ", "OK lost the keyboard value")
-- Rows shift and shrink between pages, but a key id keeps its place, so the highlight
-- drawn on press lands on the key that was pressed even after the labels change.
local function positions(page)
local at = {}
for _, key in ipairs(keys(page)) do at[key.id] = key.x end
return at
end
local lowerAt, upperAt = positions("lower"), positions("upper")
for id, x in pairs(lowerAt) do
assert(upperAt[id] == x, "key " .. id .. " moved when the page changed case")
end
print("ok")