1aa3a59ca9
The firmware knew where apps, data and modules lived, called four globals, and painted a status bar into a strip it clipped every app out of. None of that was its business, and the viewport made the bar something an app could neither compose with nor replace. It now loads one file. main.lua mounts the route inside its own node tree, so the bar is a sibling of the app rather than chrome painted over it: one layout, one hit test, no inset arithmetic and no invalidation flags on the C++ side. Apps and main.lua are tables -- they share a lua_State, so globals would collide -- and a screen changes by rebuilding from node().
463 lines
12 KiB
Lua
463 lines
12 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,
|
|
canGoBack = false,
|
|
timezone = "UTC0",
|
|
raw = nil, -- pending raw touch reading, {x, y} or nil
|
|
networks = {}, -- what wifi.scan() returns
|
|
bleDevices = {}, -- what ble.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 = {}
|
|
device.lines = {}
|
|
device.roundRects = {}
|
|
device.files = device.files or {}
|
|
device.timers = {}
|
|
device.nextTimer = 1
|
|
|
|
-- 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.invalidated = {}
|
|
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,
|
|
pressed = false,
|
|
}
|
|
return #nodes
|
|
end,
|
|
setPressed = function(id, pressed)
|
|
nodes[id].pressed = pressed and true or false
|
|
end,
|
|
isPressed = function(id)
|
|
return nodes[id].pressed
|
|
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,
|
|
-- Which nodes were asked to repaint, so a test can assert that a tick touched the
|
|
-- clock and nothing else.
|
|
invalidate = function(id)
|
|
device.invalidated[#device.invalidated + 1] = id
|
|
end,
|
|
}, {
|
|
__index = function()
|
|
return function() end
|
|
end,
|
|
})
|
|
|
|
gui = {
|
|
-- Font roles are scales of the one built-in font, matching the firmware's GuiProvider.
|
|
FONT_SMALL = 1,
|
|
FONT_UI = 2,
|
|
FONT_BODY = 2,
|
|
FONT_LARGE = 3,
|
|
STYLE_NORMAL = 0,
|
|
STYLE_BOLD = 0,
|
|
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(...)
|
|
device.lines[#device.lines + 1] = { ... }
|
|
end,
|
|
roundRect = function(...)
|
|
device.roundRects[#device.roundRects + 1] = { ... }
|
|
end,
|
|
drawPixel = function() end,
|
|
drawCircle = function() end,
|
|
fillPolygon = function() end,
|
|
drawBmp = function()
|
|
return nil, "no decoder"
|
|
end,
|
|
drawText = function(font, x, y, label)
|
|
device.painted[#device.painted + 1] = { label = label, x = x, y = y, font = font }
|
|
end,
|
|
getFontHeight = function(font)
|
|
return device.fontHeight * (font or 1)
|
|
end,
|
|
getTextWidth = function(font, text)
|
|
return #text * device.charWidth * (font or 1)
|
|
end,
|
|
setRotation = function(degrees)
|
|
device.rotation = degrees
|
|
end,
|
|
getRotation = function()
|
|
return device.rotation
|
|
end,
|
|
}
|
|
|
|
sys = {
|
|
getMillis = function()
|
|
return device.now
|
|
end,
|
|
back = function()
|
|
device.backed = true
|
|
end,
|
|
canGoBack = function()
|
|
return device.canGoBack == true
|
|
end,
|
|
getAppID = function()
|
|
return device.appName
|
|
end,
|
|
getAppTitle = function()
|
|
return device.appName
|
|
end,
|
|
setAppTitle = function(name)
|
|
device.appName = name
|
|
end,
|
|
getAppDataPath = function()
|
|
return "/.lua/data/" .. device.appName
|
|
end,
|
|
getAPIVersion = function()
|
|
return 1
|
|
end,
|
|
hasFeature = function(name)
|
|
return name == "touch"
|
|
end,
|
|
getMemory = function()
|
|
return device.freeHeap, device.totalHeap, device.largestBlock
|
|
end,
|
|
isClockSynced = function()
|
|
return device.clockSynced
|
|
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,
|
|
setCalibration = function(...)
|
|
device.calibration = { ... }
|
|
return saved()
|
|
end,
|
|
getTimezone = function()
|
|
return device.timezone
|
|
end,
|
|
setTimezone = function(tz)
|
|
device.timezone = tz
|
|
return saved()
|
|
end,
|
|
getTheme = function()
|
|
return device.theme
|
|
end,
|
|
setTheme = function(name)
|
|
device.theme = name
|
|
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 = {
|
|
MAX_READ_BYTES = 65536,
|
|
readFile = function(path)
|
|
return device.files[path]
|
|
end,
|
|
writeFile = function(path, value)
|
|
if device.saveFails then
|
|
return nil, "read only"
|
|
end
|
|
device.files[path] = value
|
|
return true
|
|
end,
|
|
exists = function(path)
|
|
return device.files[path] ~= nil
|
|
end,
|
|
listFiles = function()
|
|
return {}
|
|
end,
|
|
listDirs = function()
|
|
return {}
|
|
end,
|
|
fileSize = function(path)
|
|
local value = device.files[path]
|
|
return value and #value or nil, value and nil or "missing"
|
|
end,
|
|
}
|
|
|
|
-- Timers fire only when a test asks, so a callback's effect is asserted where it happens.
|
|
timer = {
|
|
after = function(ms, callback)
|
|
return device.addTimer(ms, callback, false)
|
|
end,
|
|
every = function(ms, callback)
|
|
return device.addTimer(ms, callback, true)
|
|
end,
|
|
cancel = function(id)
|
|
local found = device.timers[id] ~= nil
|
|
device.timers[id] = nil
|
|
return found
|
|
end,
|
|
}
|
|
|
|
wifi = {
|
|
scan = function()
|
|
return device.networks
|
|
end,
|
|
getStatus = function()
|
|
return device.status
|
|
end,
|
|
connect = function(ssid, password)
|
|
device.wifiReconnect = ssid == nil
|
|
device.connected = { ssid, password }
|
|
if ssid then
|
|
device.status.ssid = ssid
|
|
end
|
|
device.status.state = "connecting"
|
|
return saved()
|
|
end,
|
|
disconnect = function()
|
|
device.status.state = "disconnected"
|
|
end,
|
|
forget = function()
|
|
device.connected = nil
|
|
device.status = { state = "disconnected", ssid = "", ip = "", rssi = 0 }
|
|
return saved()
|
|
end,
|
|
}
|
|
|
|
ble = {
|
|
init = function(name)
|
|
device.bleInitialized, device.bleName = true, name
|
|
return true
|
|
end,
|
|
deinit = function()
|
|
device.bleInitialized = false
|
|
device.bleName, device.bleConnected, device.bleAdvertising = nil, nil, nil
|
|
end,
|
|
isInitialized = function()
|
|
return device.bleInitialized == true
|
|
end,
|
|
scan = function()
|
|
return device.bleDevices
|
|
end,
|
|
connect = function(address)
|
|
device.bleConnected = address
|
|
return true
|
|
end,
|
|
disconnect = function()
|
|
device.bleConnected = nil
|
|
end,
|
|
isConnected = function()
|
|
return device.bleConnected ~= nil
|
|
end,
|
|
read = function()
|
|
return "test"
|
|
end,
|
|
write = function(service, characteristic, value)
|
|
device.bleWrite = { service, characteristic, value }
|
|
return true
|
|
end,
|
|
startAdvertising = function(name)
|
|
device.bleAdvertising = name
|
|
return true
|
|
end,
|
|
stopAdvertising = function()
|
|
device.bleAdvertising = nil
|
|
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.
|
|
-- Registers a timer and hands back its id, mirroring timer.after/every.
|
|
function device.addTimer(intervalMs, callback, repeating)
|
|
local id = device.nextTimer
|
|
device.nextTimer = id + 1
|
|
device.timers[id] = { callback = callback, intervalMs = intervalMs, repeating = repeating }
|
|
return id
|
|
end
|
|
|
|
-- Fires every registered timer once, which is what a test means by "time passed".
|
|
function device.fireTimers()
|
|
for id, entry in pairs(device.timers) do
|
|
if not entry.repeating then
|
|
device.timers[id] = nil
|
|
end
|
|
entry.callback()
|
|
end
|
|
end
|
|
|
|
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)
|
|
local ui = require "ui"
|
|
device.tapTarget = id
|
|
ui.down(x, y)
|
|
ui.up(x, y)
|
|
device.tapTarget = nil
|
|
end
|
|
|
|
-- Loads and mounts an app the way /.lua/main.lua does on the panel, minus the chrome:
|
|
-- the bar is a node like any other and is asserted on its own.
|
|
function device.start(path, arg)
|
|
local ui = require "ui"
|
|
local app = dofile(path)
|
|
device.app = app
|
|
if app.init then
|
|
app.init(arg)
|
|
end
|
|
ui.mount(app.node)
|
|
return app
|
|
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
|