1adf121dc6
sys.startApp(path, args) tears the runtime down and starts over from a file, and keeps nothing else: no history, no title, no app identity. All of that can ride in the arguments, so it does, and sdcard/.lua/lib/nav.lua is the single writer of that table. nav owns the back stack, the bar's title and /.lua/data/<AppId>, which means the launcher, what "back" reaches and how deep a route nests are editable on the card rather than in a reflash. Apps call nav.launch, nav.replace and nav.back; sys.startApp has one caller. Boot passes no arguments at all, which is how main.lua tells a cold start from a navigation and opens its own launcher, replacing the home field the runtime used to read. Arguments cross as JSON, so history is a list of tables rather than a packed string, and an app passing something JSON cannot carry sees the error at its own nav call.
435 lines
12 KiB
Lua
435 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,
|
|
textSize = 1,
|
|
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 touch.setCalibration()
|
|
freeHeap = 200000,
|
|
totalHeap = 320000,
|
|
largestBlock = 100000,
|
|
connected = nil, -- last wifi.connect()
|
|
started = nil, -- last sys.startApp(), {path, args}
|
|
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
|
|
|
|
tree = 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,
|
|
})
|
|
|
|
screen = {
|
|
-- 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)
|
|
if degrees % 90 ~= 0 or degrees < 0 or degrees > 270 then
|
|
return false
|
|
end
|
|
device.rotation = degrees
|
|
return saved()
|
|
end,
|
|
getRotation = function()
|
|
return device.rotation
|
|
end,
|
|
getTheme = function()
|
|
return device.theme
|
|
end,
|
|
setTheme = function(name)
|
|
device.theme = name
|
|
return saved()
|
|
end,
|
|
}
|
|
|
|
sys = {
|
|
getMillis = function()
|
|
return device.now
|
|
end,
|
|
|
|
getAPIVersion = function()
|
|
return 1
|
|
end,
|
|
hasFeature = function(name)
|
|
return name == "touch" or name == "screen"
|
|
end,
|
|
getMemory = function()
|
|
return device.freeHeap, device.totalHeap, device.largestBlock
|
|
end,
|
|
isClockSynced = function()
|
|
return device.clockSynced
|
|
end,
|
|
getTimezone = function()
|
|
return device.timezone
|
|
end,
|
|
setTimezone = function(tz)
|
|
device.timezone = tz
|
|
return saved()
|
|
end,
|
|
startApp = function(path, args)
|
|
device.started = { path = path, args = args }
|
|
end,
|
|
}
|
|
|
|
touch = {
|
|
isTouched = function()
|
|
return device.raw ~= nil
|
|
end,
|
|
getPoint = function()
|
|
if not device.raw then
|
|
return nil
|
|
end
|
|
return device.raw[1], device.raw[2]
|
|
end,
|
|
getRawPoint = function()
|
|
if not device.raw then
|
|
return nil
|
|
end
|
|
return device.raw[1], device.raw[2]
|
|
end,
|
|
setCalibration = function(...)
|
|
device.calibration = { ... }
|
|
return saved()
|
|
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.
|
|
---Mounts an app the way main.lua does, minus the bar. Navigation is reset first,
|
|
---because the firmware reaches an app through a fresh state and nothing carries over.
|
|
function device.start(path, arg)
|
|
local ui = require "ui"
|
|
local nav = require "nav"
|
|
nav.start { app = path:match "apps/(.+)/main%.lua$" or path, arg = arg }
|
|
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
|