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:
+70
-99
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user