f7c5cc09ba
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.
206 lines
7.6 KiB
Lua
206 lines
7.6 KiB
Lua
-- Stand-in for the firmware bindings, so host tests exercise the real ui.lua.
|
|
--
|
|
-- One definition of the binding surface: when a binding is added or renamed, this is
|
|
-- the file that changes. Tests mutate the returned table to steer the device, and may
|
|
-- overwrite any stub function outright for a single assertion.
|
|
|
|
local device = {
|
|
-- Text metrics chosen to match the firmware's default GLCD font, since layout
|
|
-- assertions are written in real pixels.
|
|
fontHeight = 8,
|
|
charWidth = 6,
|
|
now = 0,
|
|
rotation = 0,
|
|
theme = "light",
|
|
clockSynced = false,
|
|
tickInterval = 0,
|
|
appName = "test",
|
|
textSize = 1,
|
|
fullscreen = false,
|
|
timezone = "UTC0",
|
|
raw = nil, -- pending raw touch reading, {x, y} or nil
|
|
networks = {}, -- what wifi.scan() returns
|
|
status = {state = "disconnected", ssid = "", ip = "", rssi = 0},
|
|
painted = {}, -- every drawText call, in order
|
|
calibration = nil, -- last settings.setCalibration()
|
|
freeHeap = 200000,
|
|
totalHeap = 320000,
|
|
largestBlock = 100000,
|
|
connected = nil, -- last wifi.connect()
|
|
backed = false,
|
|
launched = nil, -- last sys.launch()/replace(), {path, arg, replace}
|
|
saveFails = false, -- make every persisting call report failure
|
|
}
|
|
|
|
local function saved()
|
|
return not device.saveFails
|
|
end
|
|
|
|
function device.install()
|
|
device.painted = {}
|
|
|
|
-- The widget tree lives in the firmware. Geometry -- layout, hit testing, style
|
|
-- inheritance -- is asserted in test/ui_layout_test.cpp against the same C++ the panel
|
|
-- runs, so this fake deliberately implements none of it. What it does keep is the
|
|
-- structure an app builds, which is what app logic is actually about: a test taps the
|
|
-- control labelled "Rotation" rather than the pixel it happened to land on.
|
|
local nodes = {}
|
|
device.nodes = nodes
|
|
device.drawn = 0
|
|
device.tapTarget = nil
|
|
|
|
node = setmetatable({
|
|
reset = function()
|
|
for index in ipairs(nodes) do nodes[index] = nil end
|
|
end,
|
|
create = function(parent, spec)
|
|
nodes[#nodes + 1] = {parent = parent, label = spec.label, kind = spec.type,
|
|
interactive = spec.interactive or spec.capture}
|
|
return #nodes
|
|
end,
|
|
attach = function(parent, child) nodes[child].parent = parent end,
|
|
getParent = function(id) return nodes[id].parent end,
|
|
getLabel = function(id) return nodes[id].label end,
|
|
setLabel = function(id, text) nodes[id].label = text end,
|
|
getCount = function() return #nodes end,
|
|
layout = function() return true end,
|
|
-- Any point is inside, because which node a point covers is geometry and geometry is
|
|
-- not this file's business. Tests choose the target with device.tap().
|
|
getRect = function() return 0, 0, 10000, 10000 end,
|
|
hit = function() return device.tapTarget end,
|
|
draw = function() device.drawn = device.drawn + 1 end,
|
|
}, {__index = function() return function() end end})
|
|
|
|
gui = {
|
|
color = function(r, g, b) return r * 65536 + g * 256 + b end,
|
|
-- Rotation swaps the frame, exactly as TFT_eSPI reports it.
|
|
getWidth = function() return device.rotation % 180 == 0 and 320 or 480 end,
|
|
getHeight = function() return device.rotation % 180 == 0 and 480 or 320 end,
|
|
clear = function() end,
|
|
fillRect = function() end,
|
|
drawRect = function() end,
|
|
fillCircle = function() end,
|
|
drawLine = function() end,
|
|
roundRect = function() end,
|
|
drawText = function(label, x, y)
|
|
device.painted[#device.painted + 1] = {label = label, x = x, y = y}
|
|
end,
|
|
setTextSize = function(size) device.textSize = size end,
|
|
getFontHeight = function() return device.fontHeight * device.textSize end,
|
|
getTextWidth = function(text) return #text * device.charWidth * device.textSize end,
|
|
setRotation = function(degrees) device.rotation = degrees end,
|
|
getRotation = function() return device.rotation end,
|
|
setFullscreen = function(on) device.fullscreen = on and true or false end,
|
|
}
|
|
|
|
sys = {
|
|
getMillis = function() return device.now end,
|
|
back = function() device.backed = true end,
|
|
getAppName = function() return device.appName end,
|
|
setAppName = function(name) device.appName = name end,
|
|
getMemory = function() return device.freeHeap, device.totalHeap, device.largestBlock end,
|
|
isClockSynced = function() return device.clockSynced end,
|
|
setTickInterval = function(ms)
|
|
assert(ms == 0 or on_tick, "setTickInterval without on_tick")
|
|
device.tickInterval = ms
|
|
end,
|
|
launch = function(path, arg) device.launched = {path = path, arg = arg, replace = false} end,
|
|
replace = function(path, arg) device.launched = {path = path, arg = arg, replace = true} end,
|
|
}
|
|
|
|
settings = {
|
|
getRotation = function() return device.rotation end,
|
|
setRotation = function(degrees)
|
|
if degrees % 90 ~= 0 or degrees < 0 or degrees > 270 then return false end
|
|
device.rotation = degrees
|
|
return saved()
|
|
end,
|
|
getTheme = function() return device.theme end,
|
|
setTheme = function(name) device.theme = name return saved() end,
|
|
setCalibration = function(...) device.calibration = {...} return saved() end,
|
|
getTimezone = function() return device.timezone end,
|
|
setTimezone = function(tz) device.timezone = tz return saved() end,
|
|
}
|
|
|
|
input = {
|
|
isTouched = function() return device.raw ~= nil end,
|
|
getTouch = function()
|
|
if not device.raw then return nil end
|
|
return device.raw[1], device.raw[2]
|
|
end,
|
|
getRawTouch = function()
|
|
if not device.raw then return nil end
|
|
return device.raw[1], device.raw[2]
|
|
end,
|
|
}
|
|
|
|
fs = {
|
|
readFile = function() return nil end,
|
|
writeFile = function() return saved() end,
|
|
exists = function() return false end,
|
|
listFiles = function() return {} end,
|
|
listDirs = function() return {} end,
|
|
}
|
|
|
|
wifi = {
|
|
scan = function() return device.networks end,
|
|
getStatus = function() return device.status end,
|
|
connect = function(ssid, password)
|
|
device.connected = {ssid, password}
|
|
return saved()
|
|
end,
|
|
forget = function() device.connected = nil return saved() end,
|
|
}
|
|
|
|
log = {debug = function() end, info = function() end, error = function() end}
|
|
|
|
return device
|
|
end
|
|
|
|
-- The first node whose text starts with `prefix`, whether or not anything can press it.
|
|
function device.labelled(prefix)
|
|
for id, entry in ipairs(device.nodes) do
|
|
if entry.label and entry.label:sub(1, #prefix) == prefix then return id end
|
|
end
|
|
end
|
|
|
|
-- The nearest ancestor of that text that can be pressed: a card's label is a child of the
|
|
-- button, and it is the button a finger lands on.
|
|
function device.find(prefix)
|
|
for id, entry in ipairs(device.nodes) do
|
|
if entry.label and entry.label:sub(1, #prefix) == prefix then
|
|
local target = id
|
|
while target and not device.nodes[target].interactive do
|
|
target = device.nodes[target].parent
|
|
end
|
|
if target then return target end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Presses the control labelled `prefix`, the way a test means it: by what it says, not by
|
|
-- where it landed.
|
|
function device.tap(prefix)
|
|
local target = device.find(prefix)
|
|
if not target then error("no control labelled '" .. prefix .. "'", 2) end
|
|
device.press(target, 0, 0)
|
|
end
|
|
|
|
-- Presses a node at a point, for the widgets that have no label to aim at. getRect()
|
|
-- answers the same box here as it does inside the widget, so a custom painter's own
|
|
-- geometry decides what was hit, exactly as it does on the panel.
|
|
function device.press(id, x, y)
|
|
device.tapTarget = id
|
|
on_touch_down(x, y)
|
|
on_touch_up(x, y)
|
|
device.tapTarget = nil
|
|
end
|
|
|
|
function device.findKind(kind)
|
|
for id, entry in ipairs(device.nodes) do
|
|
if entry.kind == kind then return id end
|
|
end
|
|
end
|
|
|
|
return device
|