Compare commits

...

7 Commits

Author SHA1 Message Date
evan 445a9b2b8f fix(ui): collect after a rebuild so the heap is in a known state
A build allocates a spec table per node and drops them all at once, so an
app that scans WiFi right after a screen change met whatever the incremental
GC had got around to. Costs a few ms on a screen change; recovers ~24 KB.
2026-08-04 21:30:25 -04:00
evan 4b89b27947 feat(ui): size a grid to the app's box, not the panel
Chrome takes the top of the panel before an app builds anything, and layout
has not run yet when it does, so ui.frame() reports what the mount left it.
2026-08-04 21:15:21 -04:00
evan 25b576a3d9 refactor(gui): drop setFullscreen now that chrome is a Lua node
Fullscreen was the firmware surrendering a strip it clipped apps out of.
The strip is a sibling node now, so an app that wants the panel is chrome
choosing not to build itself.
2026-08-04 20:58:56 -04:00
evan 2fc3abc487 feat(sys): expose canGoBack for the chrome that offers the control 2026-08-04 20:55:16 -04:00
evan 3f26c4ff10 feat(ui): one mounted tree instead of a screen object per build
Chrome and the app now share a tree, so a screen is no longer something an
app constructs and holds: ui.mount() takes the function that builds the
whole thing and ui.rebuild() runs it again. Building a node after layout
is refused rather than silently resetting the arena under the panel.
2026-08-04 20:53:15 -04:00
evan e3153f57c5 feat(runtime): hand the whole app contract to /.lua/main.lua
The firmware knew four paths and called four globals, so the card could
not change its own layout or put anything around an app. It now loads one
file, and the table that file returns owns the rest: start() mounts the
route, home and data name the tree, and every callback is a field on it
rather than a global the app and its chrome would have to share.
2026-08-04 20:51:25 -04:00
evan e85adfa757 feat(settings): persist the theme through the settings binding
The palette lives in Lua, so C++ stores the name only and ui.setTheme()
writes through here before rebuilding and repainting.
2026-08-04 20:44:08 -04:00
16 changed files with 406 additions and 233 deletions
-4
View File
@@ -107,10 +107,6 @@ function gui.fillCircle(x, y, radius, color, background) end
---@param border? GuiColor Omitted for no border. ---@param border? GuiColor Omitted for no border.
function gui.roundRect(x, y, w, h, radius, background, top, bottom, border) end function gui.roundRect(x, y, w, h, radius, background, top, bottom, border) end
---Temporarily gives the app the full panel, including firmware chrome.
---@param on boolean
function gui.setFullscreen(on) end
---Fills a polygon. ---Fills a polygon.
---@param xs integer[] ---@param xs integer[]
---@param ys integer[] ---@param ys integer[]
+10 -9
View File
@@ -2,21 +2,22 @@
-- Generated from native/src/runtime/runtime.cpp. Do not edit. -- Generated from native/src/runtime/runtime.cpp. Do not edit.
-- Runtime layout: -- The firmware loads /.lua/main.lua into every fresh state and calls these on the
-- /.lua/apps/<AppId>/main.lua application entry point -- table it returns. Where apps live, what surrounds them and which of these an app
-- /.lua/apps/<AppId>/<Subapp>/main.lua nested route, omitted from the launcher -- itself sees are all main.lua's to decide.
-- /.lua/data/<AppId>/ persistent app data, preserved across updates --
-- /.lua/lib/<module>.lua shared require() modules -- Fields the firmware reads: home, the route sys.back() lands on once history is
-- require() also searches the running application's directory -- empty, and data, the sys.getAppDataPath() template whose ? is the app id.
-- --
-- The firmware does not clear the frame before calling draw(), and commits changed -- The firmware does not clear the frame before calling draw(), and commits changed
-- display content after each callback batch using the panel's own refresh policy. -- display content after each callback batch using the panel's own refresh policy.
-- Timer callbacks are registered directly with timer.after/every. -- Timer callbacks are registered directly with timer.after/every.
---Required. Runs once before the first draw; failing here stops the app. ---Required. Mounts the route; failing here leaves no app running.
---@param route string The app path sys.launch, sys.back or the boot recorded.
---@param arg? string The string passed to sys.launch or sys.replace. ---@param arg? string The string passed to sys.launch or sys.replace.
function init(arg) end function start(route, arg) end
---Optional frame loop, called once after init and then at most 30 FPS, best effort. ---Optional frame loop, called once after start and then at most 30 FPS, best effort.
---@param deltaMs integer Monotonic milliseconds since the previous draw; zero on the first. ---@param deltaMs integer Monotonic milliseconds since the previous draw; zero on the first.
function draw(deltaMs) end function draw(deltaMs) end
+12
View File
@@ -24,3 +24,15 @@ function settings.getTimezone() end
---@return true? ok ---@return true? ok
---@return string? error ---@return string? error
function settings.setTimezone(timezone) end function settings.setTimezone(timezone) end
---Returns the saved palette name. Apps read ui.getTheme() instead; this
---is the stored value, which only ui.setTheme() knows how to apply.
---@return string
function settings.getTheme() end
---Persists a palette name without applying it. Call ui.setTheme(), which
---writes through here and then rebuilds the palette and repaints.
---@param theme string
---@return true? ok
---@return string? error
function settings.setTheme(theme) end
+10 -6
View File
@@ -36,19 +36,23 @@ function sys.getAppDataPath() end
---@param title string ---@param title string
function sys.setAppTitle(title) end function sys.setAppTitle(title) end
---Launches /.lua/apps/<path>/main.lua and pushes the current route. ---Launches a route, which main.lua resolves, and pushes the current one.
---@param path string App-relative directory path; traversal is rejected. ---@param path string App-relative route; traversal is rejected.
---@param arg? string Passed to init(arg). ---@param arg? string Passed to main.start(route, arg).
function sys.launch(path, arg) end function sys.launch(path, arg) end
---Launches an app path without retaining the current route. ---Launches a route without retaining the current one.
---@param path string App-relative directory path; traversal is rejected. ---@param path string App-relative route; traversal is rejected.
---@param arg? string Passed to init(arg). ---@param arg? string Passed to main.start(route, arg).
function sys.replace(path, arg) end function sys.replace(path, arg) end
---Returns to the previous app, or the launcher when history is empty. ---Returns to the previous app, or the launcher when history is empty.
function sys.back() end function sys.back() end
---Whether sys.back() would return somewhere rather than land on the launcher, which is what chrome needs to decide whether to offer a back control.
---@return boolean
function sys.canGoBack() end
---Returns heap statistics. ---Returns heap statistics.
---@return integer freeBytes ---@return integer freeBytes
---@return integer totalBytes ---@return integer totalBytes
+77 -58
View File
@@ -42,7 +42,6 @@ local ui = {}
---@field on_cancel? UiHandler ---@field on_cancel? UiHandler
---@field on_outside? UiHandler ---@field on_outside? UiHandler
local THEME_PATH = "/.lua/theme"
local THEMES = { local THEMES = {
light = { background = { 255, 255, 255 }, color = { 0, 0, 0 }, accent = { 0, 120, 255 }, radius = 6 }, light = { background = { 255, 255, 255 }, color = { 0, 0, 0 }, accent = { 0, 120, 255 }, radius = 6 },
dark = { background = { 18, 18, 20 }, color = { 235, 235, 235 }, accent = { 166, 118, 255 }, radius = 6 }, dark = { background = { 18, 18, 20 }, color = { 235, 235, 235 }, accent = { 166, 118, 255 }, radius = 6 },
@@ -55,7 +54,10 @@ local enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {}
local pressStyles = {} local pressStyles = {}
local laidOut = false local laidOut = false
local themeName local themeName
local activeScreen -- One tree per state, built by the function ui.mount() was given. Rebuilding is
-- cheap enough that nothing is retained between screens.
local builder, root, captured, insideCaptured, confirming
local inset = 0
local applyPalette local applyPalette
local function mix(a, b, amount) local function mix(a, b, amount)
@@ -113,21 +115,20 @@ function ui.setTheme(name)
if not THEMES[name] then if not THEMES[name] then
return nil, "Unknown theme" return nil, "Unknown theme"
end end
local ok, err = fs.writeFile(THEME_PATH, name) local ok, err = settings.setTheme(name)
if not ok then if not ok then
return nil, err return nil, err
end end
loadTheme(name) loadTheme(name)
if activeScreen then if root then
applyPalette(activeScreen.root) applyPalette(root)
gui.clear(ui.theme.background) gui.clear(ui.theme.background)
node.invalidate(activeScreen.root) node.invalidate(root)
end end
return true return true
end end
local savedTheme = fs.readFile(THEME_PATH, 32) loadTheme(settings.getTheme())
loadTheme(savedTheme and savedTheme:match "^%s*(.-)%s*$" or "light")
local function clearState() local function clearState()
enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {} enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {}
@@ -171,8 +172,10 @@ end
local function build(spec, kind) local function build(spec, kind)
spec = spec or {} spec = spec or {}
-- Nodes outside a build would reset the arena under the screen already on the
-- panel, chrome included. Rebuilding is the only way to change one.
if laidOut then if laidOut then
ui.reset() error("build nodes from the function ui.mount() was given, then ui.rebuild()", 3)
end end
local children = {} local children = {}
@@ -212,13 +215,28 @@ end
---@return integer side ---@return integer side
---@return integer columns ---@return integer columns
function ui.cardSide(count, pad, gap, reserve) function ui.cardSide(count, pad, gap, reserve)
local columns = gui.getWidth() >= gui.getHeight() and 3 or 2 local width, height = ui.frame()
local columns = width >= height and 3 or 2
local rows = math.ceil(count / columns) local rows = math.ceil(count / columns)
local byWidth = (gui.getWidth() - 2 * pad - (columns - 1) * gap) // columns local byWidth = (width - 2 * pad - (columns - 1) * gap) // columns
local byHeight = (gui.getHeight() - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows local byHeight = (height - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows
return math.min(byWidth, byHeight), columns return math.min(byWidth, byHeight), columns
end end
---How much of the panel chrome took before the app was built. Set by whatever mounts the
---tree, because layout has not run yet when an app sizes itself.
---@param px integer
function ui.setInset(px)
inset = px
end
---The box the app is built into, which is the panel minus the chrome above it.
---@return integer w
---@return integer h
function ui.frame()
return gui.getWidth(), gui.getHeight() - inset
end
---@param spec UiSpec ---@param spec UiSpec
---@return NodeId ---@return NodeId
function ui.spacer(spec) function ui.spacer(spec)
@@ -328,13 +346,9 @@ function ui.reset()
node.reset() node.reset()
clearState() clearState()
laidOut = false laidOut = false
activeScreen = nil root, captured, insideCaptured, confirming = nil, nil, nil, nil
end end
---@class UiScreen
local Screen = {}
Screen.__index = Screen
applyPalette = function(root) applyPalette = function(root)
-- The root is the panel background, not a card: no border, so it takes the fast fillRect -- The root is the panel background, not a card: no border, so it takes the fast fillRect
-- path rather than the per-pixel roundRect one. Radius stays so cards inherit it. -- path rather than the per-pixel roundRect one. Radius stays so cards inherit it.
@@ -350,33 +364,39 @@ applyPalette = function(root)
}) })
end end
---@param root NodeId ---Registers the function that builds the whole tree and shows what it returns.
---@param style? NodeStyle ---@param fn fun(): NodeId
---@return UiScreen function ui.mount(fn)
function ui.screen(root, style) builder = fn
node.setSize(root, "fill", "fill") ui.rebuild()
applyPalette(root)
if style then
node.setStyle(root, style)
end
local screen = setmetatable({ root = root }, Screen)
activeScreen = screen
screen:relayout()
return screen
end end
function Screen:relayout() ---Rebuilds the tree from scratch and repaints. Screens are not retained, so this
local ok, err = node.layout(self.root, 0, 0, gui.getWidth(), gui.getHeight()) ---is how a screen changes, a rotation is answered and a dialog opens.
function ui.rebuild()
ui.reset()
root = builder()
node.setSize(root, "fill", "fill")
applyPalette(root)
local ok, err = node.layout(root, 0, 0, gui.getWidth(), gui.getHeight())
if not ok then if not ok then
error(err, 2) error(err, 2)
end end
node.dropScratch() node.dropScratch()
laidOut = true laidOut = true
gui.clear(ui.theme.background) gui.clear(ui.theme.background)
node.draw(root)
-- A build allocates a spec table per node and drops them all here, and the next thing an
-- app does may be the one that needs a contiguous WiFi buffer. Collecting now costs a few
-- milliseconds on a screen change nobody can see, and leaves the heap in a known state
-- instead of one that depends on when the incremental GC last ran.
collectgarbage()
end end
function Screen:draw() function ui.draw()
node.draw(self.root) if root then
node.draw(root)
end
end end
local function inside(id, x, y) local function inside(id, x, y)
@@ -407,7 +427,7 @@ end
---@param x integer ---@param x integer
---@param y integer ---@param y integer
---@return boolean handled ---@return boolean handled
function Screen:down(x, y) function ui.down(x, y)
local focused = node.getFocus() local focused = node.getFocus()
if focused then if focused then
node.setFocus(nil) node.setFocus(nil)
@@ -417,11 +437,11 @@ function Screen:down(x, y)
end end
end end
local target = node.hit(self.root, x, y) local target = root and node.hit(root, x, y)
if not target then if not target then
return false return false
end end
self.captured, self.inside = target, true captured, insideCaptured = target, true
enter(target, x, y) enter(target, x, y)
return true return true
end end
@@ -429,18 +449,17 @@ end
---@param x integer ---@param x integer
---@param y integer ---@param y integer
---@return boolean handled ---@return boolean handled
function Screen:move(x, y) function ui.move(x, y)
local target = self.captured if not captured then
if not target then
return false return false
end end
local isInside = inside(target, x, y) local isInside = inside(captured, x, y)
if isInside ~= self.inside then if isInside ~= insideCaptured then
self.inside = isInside insideCaptured = isInside
if isInside then if isInside then
enter(target, x, y) enter(captured, x, y)
else else
exit(target, x, y) exit(captured, x, y)
end end
end end
return true return true
@@ -449,15 +468,15 @@ end
---@param x integer ---@param x integer
---@param y integer ---@param y integer
---@return boolean handled ---@return boolean handled
function Screen:up(x, y) function ui.up(x, y)
local target = self.captured local target = captured
if not target then if not target then
return false return false
end end
local wasActive = self.inside local wasActive = insideCaptured
local releasedInside = inside(target, x, y) local releasedInside = inside(target, x, y)
local handler = releasedInside and clickHandlers[target] or nil local handler = releasedInside and clickHandlers[target] or nil
self.captured, self.inside = nil, nil captured, insideCaptured = nil, nil
if wasActive then if wasActive then
exit(target, x, y) exit(target, x, y)
end end
@@ -469,8 +488,8 @@ end
local DIRECTIONS = { up = true, down = true, left = true, right = true } local DIRECTIONS = { up = true, down = true, left = true, right = true }
local function focusFirst(screen) local function focusFirst()
local focused = node.focusFirst(screen.root) local focused = node.focusFirst(root)
if focused then if focused then
local handler = enterHandlers[focused] local handler = enterHandlers[focused]
if handler then if handler then
@@ -483,7 +502,7 @@ end
---@param name string Button name; directions and confirm are handled. ---@param name string Button name; directions and confirm are handled.
---@param pressed boolean ---@param pressed boolean
---@return boolean handled ---@return boolean handled
function Screen:button(name, pressed) function ui.buttonPress(name, pressed)
if type(pressed) ~= "boolean" then if type(pressed) ~= "boolean" then
error("button state must be boolean", 2) error("button state must be boolean", 2)
end end
@@ -494,10 +513,10 @@ function Screen:button(name, pressed)
end end
local previous = node.getFocus() local previous = node.getFocus()
if not previous then if not previous then
focusFirst(self) focusFirst()
return true return true
end end
local focused = node.moveFocus(self.root, name) local focused = node.moveFocus(root, name)
if focused ~= previous then if focused ~= previous then
local leave = exitHandlers[previous] local leave = exitHandlers[previous]
if leave then if leave then
@@ -514,16 +533,16 @@ function Screen:button(name, pressed)
if name ~= "confirm" then if name ~= "confirm" then
return false return false
end end
local focused = node.getFocus() or focusFirst(self) local focused = node.getFocus() or focusFirst()
if not focused then if not focused then
return false return false
end end
if pressed then if pressed then
node.setPressed(focused, true) node.setPressed(focused, true)
self.confirming = focused confirming = focused
else else
local target = self.confirming local target = confirming
self.confirming = nil confirming = nil
if target then if target then
node.setPressed(target, false) node.setPressed(target, false)
local handler = clickHandlers[target] local handler = clickHandlers[target]
+62 -31
View File
@@ -17,6 +17,17 @@ fs = {
end, end,
} }
local savedTheme = "light"
settings = {
getTheme = function()
return savedTheme
end,
setTheme = function(name)
savedTheme = name
return true
end,
}
local frameWidth, frameHeight = 320, 480 local frameWidth, frameHeight = 320, 480
gui = { gui = {
@@ -166,7 +177,7 @@ assert(table.concat(ui.themeNames(), ",") == "dark,light,mono")
local ok, err = ui.setTheme "missing" local ok, err = ui.setTheme "missing"
assert(ok == nil and err == "Unknown theme") assert(ok == nil and err == "Unknown theme")
assert(ui.setTheme "dark" == true) assert(ui.setTheme "dark" == true)
assert(files["/.lua/theme"] == "dark" and ui.getTheme() == "dark") assert(savedTheme == "dark" and ui.getTheme() == "dark")
local events = {} local events = {}
local function handler(name) local function handler(name)
@@ -175,27 +186,30 @@ local function handler(name)
end end
end end
local first = ui.button { local first, second
label = "one", ui.mount(function()
on_enter = handler "enter", first = ui.button {
on_exit = handler "exit", label = "one",
on_click = handler "click", on_enter = handler "enter",
} on_exit = handler "exit",
local second = ui.button { on_click = handler "click",
label = "two", }
on_enter = handler "enter", second = ui.button {
on_exit = handler "exit", label = "two",
on_click = handler "click", on_enter = handler "enter",
} on_exit = handler "exit",
local screen = ui.screen(ui.box { row = true, first, second }) on_click = handler "click",
}
return ui.box { row = true, first, second }
end)
assert(screen:down(10, 10)) assert(ui.down(10, 10))
assert(node.isPressed(first)) assert(node.isPressed(first))
assert(screen:move(95, 10)) assert(ui.move(95, 10))
assert(not node.isPressed(first)) assert(not node.isPressed(first))
assert(screen:move(10, 10)) assert(ui.move(10, 10))
assert(node.isPressed(first)) assert(node.isPressed(first))
assert(screen:up(10, 10)) assert(ui.up(10, 10))
assert(not node.isPressed(first)) assert(not node.isPressed(first))
local expectedTouch = { "enter", "exit", "enter", "exit", "click" } local expectedTouch = { "enter", "exit", "enter", "exit", "click" }
@@ -207,16 +221,16 @@ for index, name in ipairs(expectedTouch) do
end end
events = {} events = {}
assert(screen:button("right", true)) assert(ui.buttonPress("right", true))
assert(screen:button("right", false)) assert(ui.buttonPress("right", false))
assert(node.getFocus() == first) assert(node.getFocus() == first)
assert(screen:button("right", true)) assert(ui.buttonPress("right", true))
assert(node.getFocus() == second) assert(node.getFocus() == second)
assert(screen:button("confirm", true)) assert(ui.buttonPress("confirm", true))
assert(node.isPressed(second)) assert(node.isPressed(second))
assert(screen:button("confirm", false)) assert(ui.buttonPress("confirm", false))
assert(not node.isPressed(second)) assert(not node.isPressed(second))
assert(screen:button("back", true) == false) assert(ui.buttonPress("back", true) == false)
local expectedButtons = { local expectedButtons = {
{ "enter", first }, { "enter", first },
@@ -235,7 +249,13 @@ assert(ui.setTheme "mono" == true)
assert(ui.getTheme() == "mono" and #invalidated == before + 1) assert(ui.getTheme() == "mono" and #invalidated == before + 1)
assert(cleared == ui.theme.background) assert(cleared == ui.theme.background)
screen:draw() ui.draw()
-- The inset chrome took comes off the height budget before anything else.
ui.setInset(44)
assert(select(2, ui.frame()) == 436, "the frame is the panel minus the chrome")
assert(select(1, ui.cardSide(5, 12, 8)) == 132, "a grid fits the app's box, not the panel")
ui.setInset(0)
-- 320x480 portrait: two columns, and the reserve comes off the height budget. -- 320x480 portrait: two columns, and the reserve comes off the height budget.
local side, columns = ui.cardSide(5, 12, 8) local side, columns = ui.cardSide(5, 12, 8)
@@ -254,21 +274,32 @@ local pressedCalls = {}
node.setPressed = function(_, on) node.setPressed = function(_, on)
pressedCalls[#pressedCalls + 1] = on pressedCalls[#pressedCalls + 1] = on
end end
local own = ui.custom { h = 20, press_style = false, on_click = function() end } local own, styled
local styled = ui.button { h = 20, label = "ok", on_click = function() end } ui.mount(function()
local board = ui.screen(ui.box { own, styled }) own = ui.custom { h = 20, press_style = false, on_click = function() end }
styled = ui.button { h = 20, label = "ok", on_click = function() end }
return ui.box { own, styled }
end)
local target local target
node.hit = function() node.hit = function()
return target return target
end end
target = own target = own
board:down(0, 0) ui.down(0, 0)
board:up(0, 0) ui.up(0, 0)
assert(#pressedCalls == 0, "a self-painting widget is not styled on press") assert(#pressedCalls == 0, "a self-painting widget is not styled on press")
target = styled target = styled
board:down(0, 0) ui.down(0, 0)
assert(pressedCalls[1] == true, "an ordinary widget still gets its pressed style") assert(pressedCalls[1] == true, "an ordinary widget still gets its pressed style")
-- Building outside a rebuild would reset the arena under the screen on the panel.
local built = pcall(ui.button, { label = "stray" })
assert(not built, "a node built after layout is refused")
local rebuilt = false
ui.rebuild()
rebuilt = true
assert(rebuilt and ui.down(0, 0), "a rebuild replaces the screen and keeps dispatch live")
print "ok" print "ok"
+4 -3
View File
@@ -42,6 +42,10 @@ public:
virtual Status setRotation(int32_t degrees) = 0; virtual Status setRotation(int32_t degrees) = 0;
virtual std::string timezone() const = 0; virtual std::string timezone() const = 0;
virtual Status setTimezone(const std::string& timezone) = 0; virtual Status setTimezone(const std::string& timezone) = 0;
// Only the name of a palette; the colours themselves live in Lua, so the
// firmware can read the saved theme before a lua_State exists.
virtual std::string theme() const = 0;
virtual Status setTheme(const std::string& theme) = 0;
}; };
// Only what the firmware alone can answer. App identity, titles, data paths, // Only what the firmware alone can answer. App identity, titles, data paths,
@@ -132,9 +136,6 @@ public:
virtual void roundRect(int32_t x, int32_t y, int32_t w, int32_t h, virtual void roundRect(int32_t x, int32_t y, int32_t w, int32_t h,
int32_t radius, int32_t background, const int32_t* top, int32_t radius, int32_t background, const int32_t* top,
const int32_t* bottom, const int32_t* border) = 0; const int32_t* bottom, const int32_t* border) = 0;
// Hands the app the whole panel, including whatever chrome the firmware
// paints.
virtual void setFullscreen(bool on) = 0;
// Applies everything drawn since the last commit. The runtime supplies only // Applies everything drawn since the last commit. The runtime supplies only
// the timing -- the end of a callback batch -- because that is the one fact a // the timing -- the end of a callback batch -- because that is the one fact a
// driver cannot know; which region to touch, which waveform, and whether to // driver cannot know; which region to touch, which waveform, and whether to
+27 -20
View File
@@ -15,14 +15,10 @@ namespace esp32lua {
// build. // build.
constexpr int32_t API_VERSION = 1; constexpr int32_t API_VERSION = 1;
// Where the runtime looks for apps, their data, and shared modules. // The one path the firmware knows. Everything below it -- where apps live,
struct Paths { // where their data goes, what chrome surrounds them -- is decided by the table
std::string apps = "/.lua/apps"; // this file returns.
std::string data = "/.lua/data"; constexpr const char* MAIN_PATH = "/.lua/main.lua";
std::string lib = "/.lua/lib";
// Where sys.back() lands once history is empty. It is an app like any other.
std::string home = "Home";
};
// Firmware supplies every core provider; a null feature provider is how // Firmware supplies every core provider; a null feature provider is how
// sys.hasFeature() answers false, and its namespace additions are simply never // sys.hasFeature() answers false, and its namespace additions are simply never
@@ -44,7 +40,7 @@ struct Providers {
class Runtime { class Runtime {
public: public:
explicit Runtime(const Providers& providers, const Paths& paths = Paths()); explicit Runtime(const Providers& providers);
~Runtime(); ~Runtime();
Runtime(const Runtime&) = delete; Runtime(const Runtime&) = delete;
@@ -55,9 +51,9 @@ public:
void close(); void close();
lua_State* state() const { return state_; } lua_State* state() const { return state_; }
// Replaces the running app with a fresh lua_State, loads // Replaces the running app with a fresh lua_State, loads main.lua, and hands
// <apps>/<path>/main.lua, and calls init(arg). A failure leaves no app // it the route through start(route, arg). A failure leaves no app running
// running rather than a half-built one. // rather than a half-built one.
bool startApp(const std::string& path, bool startApp(const std::string& path,
const std::string& arg = std::string()); const std::string& arg = std::string());
bool hasApp() const { return !appPath_.empty(); } bool hasApp() const { return !appPath_.empty(); }
@@ -99,11 +95,11 @@ public:
ui::Tree& tree() { return tree_; } ui::Tree& tree() { return tree_; }
// Entry points into the app. The firmware decides whether an event reaches // Entry points into main.lua, which forwards whatever the app it mounted
// the app at all -- jitter, chrome and debouncing are its business -- and the // defines. The firmware decides whether an event happens at all -- jitter and
// runtime decides what the app sees. Only a failed init() stops an app; every // debouncing are its business -- and main.lua decides who sees it. Only a
// other callback logs and carries on. // failed start() stops an app; every other callback logs and carries on.
bool callInit(const std::string& arg); bool callStart(const std::string& route, const std::string& arg);
void callDraw(int32_t deltaMs); void callDraw(int32_t deltaMs);
// An Up phase also fires the on_touch tap alias, in that order. // An Up phase also fires the on_touch tap alias, in that order.
void callTouch(TouchPhase phase, int32_t x, int32_t y); void callTouch(TouchPhase phase, int32_t x, int32_t y);
@@ -136,7 +132,12 @@ private:
}; };
bool loadScript(const std::string& path); bool loadScript(const std::string& path);
void installLoader(const std::string& appDir); // Runs main.lua and keeps the table it returns; the app is mounted by it, not
// by the runtime.
bool loadMain();
void installLoader();
// A field of the main table, or the fallback when main.lua names none.
std::string mainField(const char* key, const char* fallback);
static int searchModule(lua_State* state); static int searchModule(lua_State* state);
static int searchEmbedded(lua_State* state); static int searchEmbedded(lua_State* state);
static int loadFile(lua_State* state); static int loadFile(lua_State* state);
@@ -155,9 +156,10 @@ private:
Runtime& runtime_; Runtime& runtime_;
}; };
// Pushes the named global, or returns false when the app does not define it. // Pushes main.<name>, or returns false when main.lua defines no such handler.
bool beginCall(const char* name); bool beginCall(const char* name);
bool finishCall(const char* name, int argc); bool finishCall(const char* name, int argc);
bool finishCallValue(const char* name);
void cancelAllTimers(); void cancelAllTimers();
Providers providers_; Providers providers_;
@@ -167,7 +169,12 @@ private:
TimerId nextTimerId_ = 1; TimerId nextTimerId_ = 1;
int batchDepth_ = 0; int batchDepth_ = 0;
Paths paths_; // Registry reference to the table main.lua returned, or 0 before one loads.
int mainRef_ = 0;
// Read from main.lua once per load, because sys.back() out of the last app
// needs the route after that app's state is gone.
std::string home_;
std::string dataTemplate_;
std::string appPath_; std::string appPath_;
std::string appTitle_; std::string appTitle_;
std::vector<Route> history_; std::vector<Route> history_;
-9
View File
@@ -125,12 +125,6 @@ int roundRect(lua_State* state) {
return 0; return 0;
} }
int setFullscreen(lua_State* state) {
luaL_checkany(state, 1);
provider(state).setFullscreen(lua_toboolean(state, 1) != 0);
return 0;
}
void readIntegers(lua_State* state, int index, std::vector<int32_t>& out) { void readIntegers(lua_State* state, int index, std::vector<int32_t>& out) {
const lua_Integer count = luaL_len(state, index); const lua_Integer count = luaL_len(state, index);
for (lua_Integer at = 1; at <= count; at++) { for (lua_Integer at = 1; at <= count; at++) {
@@ -291,9 +285,6 @@ const luaL_Reg FUNCTIONS[] = {
// without a gradient use top. // without a gradient use top.
// @param border GuiColor|nil Omitted for no border. // @param border GuiColor|nil Omitted for no border.
{"roundRect", roundRect}, {"roundRect", roundRect},
// ---Temporarily gives the app the full panel, including firmware chrome.
// @param on boolean
{"setFullscreen", setFullscreen},
// --- Fills a polygon. // --- Fills a polygon.
// @param xs integer[] // @param xs integer[]
// @param ys integer[] // @param ys integer[]
+23
View File
@@ -47,6 +47,19 @@ int setTimezone(lua_State* state) {
state, Runtime::from(state)->settings().setTimezone({value, length})); state, Runtime::from(state)->settings().setTimezone({value, length}));
} }
int getTheme(lua_State* state) {
const std::string theme = Runtime::from(state)->settings().theme();
lua_pushlstring(state, theme.data(), theme.size());
return 1;
}
int setTheme(lua_State* state) {
size_t length = 0;
const char* value = luaL_checklstring(state, 1, &length);
return pushStatus(state,
Runtime::from(state)->settings().setTheme({value, length}));
}
const luaL_Reg FUNCTIONS[] = { const luaL_Reg FUNCTIONS[] = {
// --- Returns the saved rotation in degrees clockwise. // --- Returns the saved rotation in degrees clockwise.
// @return integer // @return integer
@@ -64,6 +77,16 @@ const luaL_Reg FUNCTIONS[] = {
// @return true|nil ok // @return true|nil ok
// @return string|nil error // @return string|nil error
{"setTimezone", setTimezone}, {"setTimezone", setTimezone},
// --- Returns the saved palette name. Apps read ui.getTheme() instead; this
// --- is the stored value, which only ui.setTheme() knows how to apply.
// @return string
{"getTheme", getTheme},
// --- Persists a palette name without applying it. Call ui.setTheme(), which
// --- writes through here and then rebuilds the palette and repaints.
// @param theme string
// @return true|nil ok
// @return string|nil error
{"setTheme", setTheme},
{nullptr, nullptr}, {nullptr, nullptr},
}; };
+16 -6
View File
@@ -50,6 +50,10 @@ int back(lua_State* state) {
Runtime::from(state)->requestBack(); Runtime::from(state)->requestBack();
return 0; return 0;
} }
int canGoBack(lua_State* state) {
lua_pushboolean(state, Runtime::from(state)->canGoBack());
return 1;
}
int getMemory(lua_State* state) { int getMemory(lua_State* state) {
const MemoryInfo memory = Runtime::from(state)->sys().memory(); const MemoryInfo memory = Runtime::from(state)->sys().memory();
@@ -88,16 +92,22 @@ const luaL_Reg FUNCTIONS[] = {
// --- Changes the running app's display title. // --- Changes the running app's display title.
// @param title string // @param title string
{"setAppTitle", setAppTitle}, {"setAppTitle", setAppTitle},
// --- Launches /.lua/apps/<path>/main.lua and pushes the current route. // --- Launches a route, which main.lua resolves, and pushes the current
// @param path string App-relative directory path; traversal is rejected. // one.
// @param arg string|nil Passed to init(arg). // @param path string App-relative route; traversal is rejected.
// @param arg string|nil Passed to main.start(route, arg).
{"launch", launch}, {"launch", launch},
// --- Launches an app path without retaining the current route. // --- Launches a route without retaining the current one.
// @param path string App-relative directory path; traversal is rejected. // @param path string App-relative route; traversal is rejected.
// @param arg string|nil Passed to init(arg). // @param arg string|nil Passed to main.start(route, arg).
{"replace", replace}, {"replace", replace},
// --- Returns to the previous app, or the launcher when history is empty. // --- Returns to the previous app, or the launcher when history is empty.
{"back", back}, {"back", back},
// --- Whether sys.back() would return somewhere rather than land on the
// launcher, which is what chrome needs to decide whether to offer a back
// control.
// @return boolean
{"canGoBack", canGoBack},
// --- Returns heap statistics. // --- Returns heap statistics.
// @return integer freeBytes // @return integer freeBytes
// @return integer totalBytes // @return integer totalBytes
File diff suppressed because one or more lines are too long
+3 -5
View File
@@ -123,13 +123,11 @@ int Runtime::loadFile(lua_State* state) {
return 2; return 2;
} }
void Runtime::installLoader(const std::string& appDir) { // package.path is left to main.lua, which is loaded by absolute path and knows
// where its libraries and its apps are.
void Runtime::installLoader() {
lua_getglobal(state_, "package"); lua_getglobal(state_, "package");
const std::string path = appDir + "/?.lua;" + paths_.lib + "/?.lua";
lua_pushlstring(state_, path.data(), path.size());
lua_setfield(state_, -2, "path");
// Keep the preload searcher, drop the C loaders: they can only report // Keep the preload searcher, drop the C loaders: they can only report
// misleading errors about shared objects that were never there. // misleading errors about shared objects that were never there.
lua_getfield(state_, -1, "searchers"); lua_getfield(state_, -1, "searchers");
+80 -32
View File
@@ -45,8 +45,7 @@ bool isSafeRoute(const std::string& path) {
} // namespace } // namespace
Runtime::Runtime(const Providers& providers, const Paths& paths) Runtime::Runtime(const Providers& providers) : providers_(providers) {}
: providers_(providers), paths_(paths) {}
Runtime::~Runtime() { close(); } Runtime::~Runtime() { close(); }
@@ -91,6 +90,7 @@ void Runtime::close() {
cancelAllTimers(); cancelAllTimers();
lua_close(state_); lua_close(state_);
state_ = nullptr; state_ = nullptr;
mainRef_ = 0; // the registry it referenced went with the state
// Node handles mean nothing to the next lua_State, so an app that inherited // Node handles mean nothing to the next lua_State, so an app that inherited
// the previous tree would build onto its nodes. // the previous tree would build onto its nodes.
tree_.reset(); tree_.reset();
@@ -108,13 +108,40 @@ Runtime::Batch::~Batch() {
} }
bool Runtime::beginCall(const char* name) { bool Runtime::beginCall(const char* name) {
lua_getglobal(state_, name); if (!mainRef_)
return false;
lua_rawgeti(state_, LUA_REGISTRYINDEX, mainRef_);
lua_getfield(state_, -1, name);
lua_remove(state_, -2);
if (lua_isfunction(state_, -1)) if (lua_isfunction(state_, -1))
return true; return true;
lua_pop(state_, 1); lua_pop(state_, 1);
return false; return false;
} }
std::string Runtime::mainField(const char* key, const char* fallback) {
if (!mainRef_)
return fallback;
lua_rawgeti(state_, LUA_REGISTRYINDEX, mainRef_);
lua_getfield(state_, -1, key);
const char* value = lua_tostring(state_, -1);
const std::string result = value ? value : fallback;
lua_pop(state_, 2);
return result;
}
// Calls a chunk or handler that leaves one value on the stack; the caller owns
// it.
bool Runtime::finishCallValue(const char* name) {
if (lua_pcall(state_, 0, 1, 0) == LUA_OK)
return true;
const char* message = lua_tostring(state_, -1);
providers_.log->write(LogLevel::Error, std::string(name) + ": " +
(message ? message : "failed"));
lua_pop(state_, 1);
return false;
}
bool Runtime::finishCall(const char* name, int argc) { bool Runtime::finishCall(const char* name, int argc) {
if (lua_pcall(state_, argc, 0, 0) == LUA_OK) if (lua_pcall(state_, argc, 0, 0) == LUA_OK)
return true; return true;
@@ -126,17 +153,16 @@ bool Runtime::finishCall(const char* name, int argc) {
} }
// @lua-global core/runtime // @lua-global core/runtime
// @lua-preamble -- Runtime layout: // @lua-preamble -- The firmware loads /.lua/main.lua into every fresh state and
// @lua-preamble -- /.lua/apps/<AppId>/main.lua application entry // calls these on the
// point // @lua-preamble -- table it returns. Where apps live, what surrounds them and
// @lua-preamble -- /.lua/apps/<AppId>/<Subapp>/main.lua nested route, // which of these an app
// omitted from the launcher // @lua-preamble -- itself sees are all main.lua's to decide.
// @lua-preamble -- /.lua/data/<AppId>/ persistent app // @lua-preamble --
// data, preserved across updates // @lua-preamble -- Fields the firmware reads: home, the route sys.back() lands
// @lua-preamble -- /.lua/lib/<module>.lua shared require() // on once history is
// modules // @lua-preamble -- empty, and data, the sys.getAppDataPath() template whose ?
// @lua-preamble -- require() also searches the running application's // is the app id.
// directory
// @lua-preamble -- // @lua-preamble --
// @lua-preamble -- The firmware does not clear the frame before calling draw(), // @lua-preamble -- The firmware does not clear the frame before calling draw(),
// and commits changed // and commits changed
@@ -145,20 +171,22 @@ bool Runtime::finishCall(const char* name, int argc) {
// @lua-preamble -- Timer callbacks are registered directly with // @lua-preamble -- Timer callbacks are registered directly with
// timer.after/every. // timer.after/every.
// ---Required. Runs once before the first draw; failing here stops the app. // ---Required. Mounts the route; failing here leaves no app running.
// @param route string The app path sys.launch, sys.back or the boot recorded.
// @param arg string|nil The string passed to sys.launch or sys.replace. // @param arg string|nil The string passed to sys.launch or sys.replace.
// @lua-fn init // @lua-fn start
bool Runtime::callInit(const std::string& arg) { bool Runtime::callStart(const std::string& route, const std::string& arg) {
const Batch batch(*this); const Batch batch(*this);
if (!beginCall("init")) { if (!beginCall("start")) {
providers_.log->write(LogLevel::Error, "init: the app defines none"); providers_.log->write(LogLevel::Error, "start: main.lua defines none");
return false; return false;
} }
lua_pushlstring(state_, route.data(), route.size());
lua_pushlstring(state_, arg.data(), arg.size()); lua_pushlstring(state_, arg.data(), arg.size());
return finishCall("init", 1); return finishCall("start", 2);
} }
// ---Optional frame loop, called once after init and then at most 30 FPS, best // ---Optional frame loop, called once after start and then at most 30 FPS, best
// effort. // effort.
// @param deltaMs integer Monotonic milliseconds since the previous draw; zero // @param deltaMs integer Monotonic milliseconds since the previous draw; zero
// on the first. // on the first.
@@ -280,7 +308,15 @@ std::string Runtime::appId() const {
return slash == std::string::npos ? appPath_ : appPath_.substr(0, slash); return slash == std::string::npos ? appPath_ : appPath_.substr(0, slash);
} }
std::string Runtime::appDataPath() const { return paths_.data + "/" + appId(); } // The template is main.lua's; substituting the app id here keeps every app on
// its own directory whatever tree the card uses.
std::string Runtime::appDataPath() const {
const size_t mark = dataTemplate_.find('?');
if (mark == std::string::npos)
return dataTemplate_;
return dataTemplate_.substr(0, mark) + appId() +
dataTemplate_.substr(mark + 1);
}
bool Runtime::hasFeature(const std::string& feature) const { bool Runtime::hasFeature(const std::string& feature) const {
if (feature == "touch") if (feature == "touch")
@@ -302,21 +338,33 @@ bool Runtime::startApp(const std::string& path, const std::string& arg) {
appPath_ = path; appPath_ = path;
appTitle_ = appId(); appTitle_ = appId();
const std::string directory = paths_.apps + "/" + path; installLoader();
installLoader(directory); // main.lua runs first and start() mounts the route, so an app that fails
if (!loadScript(directory + "/main.lua")) { // either way leaves nothing behind.
if (!loadMain() || !callStart(path, arg)) {
close();
return false;
}
return true;
}
bool Runtime::loadMain() {
if (!loadScript(MAIN_PATH)) {
providers_.log->write(LogLevel::Error, lua_tostring(state_, -1) providers_.log->write(LogLevel::Error, lua_tostring(state_, -1)
? lua_tostring(state_, -1) ? lua_tostring(state_, -1)
: "load failed"); : "cannot load main.lua");
close();
return false; return false;
} }
// The chunk body runs first, then init(), so an app that fails either way if (!finishCallValue("main.lua"))
// leaves nothing behind. return false;
if (!finishCall("main.lua", 0) || !callInit(arg)) { if (!lua_istable(state_, -1)) {
close(); providers_.log->write(LogLevel::Error, "main.lua returned no table");
lua_pop(state_, 1);
return false; return false;
} }
mainRef_ = luaL_ref(state_, LUA_REGISTRYINDEX);
home_ = mainField("home", "Home");
dataTemplate_ = mainField("data", "/.lua/data/?");
return true; return true;
} }
@@ -341,7 +389,7 @@ bool Runtime::applyPendingNavigation() {
if (pending.kind == Pending::Back) { if (pending.kind == Pending::Back) {
// An empty history means the launcher, which is an app like any other. // An empty history means the launcher, which is an app like any other.
Route target; Route target;
target.path = paths_.home; target.path = home_;
if (!history_.empty()) { if (!history_.empty()) {
target = history_.back(); target = history_.back();
history_.pop_back(); history_.pop_back();
+6 -2
View File
@@ -26,6 +26,7 @@ struct Log : LogProvider {
struct Settings : SettingsProvider { struct Settings : SettingsProvider {
int32_t degrees = 0; int32_t degrees = 0;
std::string tz = "UTC0"; std::string tz = "UTC0";
std::string themeName = "light";
int32_t rotation() const override { return degrees; } int32_t rotation() const override { return degrees; }
Status setRotation(int32_t value) override { Status setRotation(int32_t value) override {
degrees = value; degrees = value;
@@ -36,6 +37,11 @@ struct Settings : SettingsProvider {
tz = value; tz = value;
return Status::success(); return Status::success();
} }
std::string theme() const override { return themeName; }
Status setTheme(const std::string& value) override {
themeName = value;
return Status::success();
}
}; };
struct Sys : SysProvider { struct Sys : SysProvider {
@@ -148,7 +154,6 @@ struct Fs : FsProvider {
struct Gui : GuiProvider { struct Gui : GuiProvider {
std::string trace; std::string trace;
int32_t degrees = 0; int32_t degrees = 0;
bool fullscreen = false;
bool gradient = false; bool gradient = false;
FontIds fonts() const override { FontIds fonts() const override {
@@ -189,7 +194,6 @@ struct Gui : GuiProvider {
trace += border ? ",border" : ",-"; trace += border ? ",border" : ",-";
trace += ");"; trace += ");";
} }
void setFullscreen(bool on) override { fullscreen = on; }
// Stands in for an e-ink panel, where a second commit is a second visible // Stands in for an e-ink panel, where a second commit is a second visible
// refresh. // refresh.
void commit() override { commits++; } void commit() override { commits++; }
+75 -47
View File
@@ -129,12 +129,10 @@ int main() {
"'confirm')"); "'confirm')");
assert(bench.touch.calibration[3] == 400); assert(bench.touch.calibration[3] == 400);
// Chrome control and gradients are core: an e-ink provider flattens what it // Gradients are core: an e-ink provider flattens what it cannot show.
// cannot show. run(state, "gui.roundRect(0, 0, 10, 10, 4, 0, 0xFF, 0x00)\n"
run(state, "gui.setFullscreen(true)\n"
"gui.roundRect(0, 0, 10, 10, 4, 0, 0xFF, 0x00)\n"
"gui.roundRect(0, 0, 10, 10, 4, 0, nil, nil, 0xFF)"); "gui.roundRect(0, 0, 10, 10, 4, 0, nil, nil, 0xFF)");
assert(bench.gui.fullscreen && bench.gui.gradient); assert(bench.gui.gradient);
assert(bench.gui.trace.find("roundRect(-,border);") != std::string::npos); assert(bench.gui.trace.find("roundRect(-,border);") != std::string::npos);
bench.gui.trace.clear(); bench.gui.trace.clear();
@@ -178,35 +176,6 @@ int main() {
"node.draw(root)\n" "node.draw(root)\n"
"assert(painted == 320)"); "assert(painted == 320)");
// Callbacks: only init failing stops an app, and a release fires the tap
// alias after the up.
run(state,
"events = {}\n"
"local function note(name) return function(a) events[#events + 1] = name "
".. ':' .. tostring(a) end end\n"
"function init(arg) events[#events + 1] = 'init:' .. tostring(arg) end\n"
"function draw(delta) events[#events + 1] = 'draw:' .. delta end\n"
"on_touch_down = note('down')\n"
"on_touch_up = note('up')\n"
"on_touch = note('tap')\n"
"on_button_up = note('bup')\n"
"on_button = note('btap')");
bench.gui.commits = 0;
assert(runtime.callInit("book.epub"));
runtime.callDraw(33);
runtime.callTouch(esp32lua::TouchPhase::Down, 5, 6);
runtime.callTouch(esp32lua::TouchPhase::Move, 5,
7); // the app defines no on_touch_move
runtime.callTouch(esp32lua::TouchPhase::Up, 5, 8);
runtime.callButton("confirm", false);
run(state,
"assert(table.concat(events, ' ') == "
"'init:book.epub draw:33 down:5 up:5 tap:5 bup:confirm btap:confirm')");
// One commit per visit to the app, so six calls and not seven: the release
// and its tap alias are one visible change, and the move nobody handled still
// ends a batch.
assert(bench.gui.commits == 6);
// A timer firing inside draw is still one batch. // A timer firing inside draw is still one batch.
run(state, "function draw() timer.after(1, function() end) end\n" run(state, "function draw() timer.after(1, function() end) end\n"
"nested = timer.after(1, function() draw() end)"); "nested = timer.after(1, function() draw() end)");
@@ -214,12 +183,57 @@ int main() {
runtime.callTimer(bench.timer.scheduled.back()); runtime.callTimer(bench.timer.scheduled.back());
assert(bench.gui.commits == 1); assert(bench.gui.commits == 1);
run(state, "function init() error('boom') end"); // Callbacks land on the table main.lua returns, only start() failing stops an
assert(!runtime.callInit("")); // app, and a release fires the tap alias after the up.
assert(bench.log.message.find("init: ") == 0); {
run(state, "function draw() error('kaboom') end"); fake::Bench chrome;
runtime.callDraw(1); // a failed frame logs and the app keeps running chrome.fs.files["/.lua/main.lua"] =
assert(bench.log.message.find("draw: ") == 0); "events = {}\n"
"local function note(name) return function(a) events[#events + 1] = "
"name .. ':' .. tostring(a) end end\n"
"return {\n"
" start = function(route, arg) events[#events + 1] = 'start:' .. "
"route .. ':' .. arg end,\n"
" draw = function(delta) if delta < 0 then error('kaboom') end\n"
" events[#events + 1] = 'draw:' .. delta end,\n"
" on_touch_down = note('down'),\n"
" on_touch_up = note('up'),\n"
" on_touch = note('tap'),\n"
" on_button_up = note('bup'),\n"
" on_button = note('btap'),\n"
"}\n";
esp32lua::Runtime hosted(chrome.providers());
assert(hosted.startApp("Reader", "book.epub"));
chrome.gui.commits = 0;
hosted.callDraw(33);
hosted.callTouch(esp32lua::TouchPhase::Down, 5, 6);
hosted.callTouch(esp32lua::TouchPhase::Move, 5,
7); // main.lua defines no on_touch_move
hosted.callTouch(esp32lua::TouchPhase::Up, 5, 8);
hosted.callButton("confirm", false);
run(hosted.state(),
"assert(table.concat(events, ' ') == "
"'start:Reader:book.epub draw:33 down:5 up:5 tap:5 bup:confirm "
"btap:confirm')");
// One commit per visit, so five calls and not six: the release and its tap
// alias are one visible change, and the move nobody handled still ends a
// batch.
assert(chrome.gui.commits == 5);
hosted.callDraw(-1); // a failed frame logs and the app keeps running
assert(chrome.log.message.find("draw: ") == 0);
assert(hosted.hasApp());
chrome.fs.files["/.lua/main.lua"] =
"return { start = function() error('boom') end }";
assert(!hosted.startApp("Reader"));
assert(chrome.log.message.find("start: ") == 0);
assert(!hosted.hasApp());
chrome.fs.files["/.lua/main.lua"] = "return 7";
assert(!hosted.startApp("Reader"));
assert(chrome.log.message == "main.lua returned no table");
}
// A feature callback without its provider is a wiring bug, not a silent // A feature callback without its provider is a wiring bug, not a silent
// no-op. // no-op.
@@ -235,22 +249,36 @@ int main() {
"assert(input.getTouch == nil and input.isPressed ~= nil)"); "assert(input.getTouch == nil and input.isPressed ~= nil)");
} }
// App loading: a fresh state per app, require reaching the app directory and // App loading: a fresh state per app, main.lua deciding where apps and
// /.lua/lib, and navigation applied between batches rather than inside a // modules live, and navigation applied between batches rather than inside a
// callback. // callback.
{ {
fake::Bench host; fake::Bench host;
// The tree is main.lua's, so the test states it the way a card would.
host.fs.files["/.lua/main.lua"] =
"package.path = '/.lua/lib/?.lua'\n"
"return {\n"
" home = 'Home',\n"
" data = '/.lua/data/?',\n"
" start = function(route, arg)\n"
" local dir = '/.lua/apps/' .. route\n"
" package.path = dir .. '/?.lua;/.lua/lib/?.lua'\n"
" app = assert(loadfile(dir .. '/main.lua'))()\n"
" app.init(arg)\n"
" end,\n"
"}\n";
host.fs.files["/.lua/lib/greet.lua"] = host.fs.files["/.lua/lib/greet.lua"] =
"return {hello = function() return 'hi' end}"; "return {hello = function() return 'hi' end}";
host.fs.files["/.lua/apps/Home/main.lua"] = host.fs.files["/.lua/apps/Home/main.lua"] =
"local greet = require('greet')\n" "local greet = require('greet')\n"
"function init(arg) started = greet.hello() .. ':' .. tostring(arg) " "return {init = function(arg) started = greet.hello() .. ':' .. "
"end"; "tostring(arg) end}";
host.fs.files["/.lua/apps/Reader/main.lua"] = host.fs.files["/.lua/apps/Reader/main.lua"] =
"local page = require('page')\n" "local page = require('page')\n"
"function init(arg) started = page.name .. ':' .. arg end"; "return {init = function(arg) started = page.name .. ':' .. arg end}";
host.fs.files["/.lua/apps/Reader/page.lua"] = "return {name = 'page'}"; host.fs.files["/.lua/apps/Reader/page.lua"] = "return {name = 'page'}";
host.fs.files["/.lua/apps/Reader/Notes/main.lua"] = "function init() end"; host.fs.files["/.lua/apps/Reader/Notes/main.lua"] =
"return {init = function() end}";
esp32lua::Runtime app(host.providers()); esp32lua::Runtime app(host.providers());
assert(app.startApp("Home")); assert(app.startApp("Home"));
@@ -289,7 +317,7 @@ int main() {
assert(!app.startApp("Absent")); assert(!app.startApp("Absent"));
assert(!app.hasApp() && app.state() == nullptr); assert(!app.hasApp() && app.state() == nullptr);
host.fs.files["/.lua/apps/Broken/main.lua"] = host.fs.files["/.lua/apps/Broken/main.lua"] =
"function init() error('nope') end"; "return {init = function() error('nope') end}";
assert(!app.startApp("Broken")); assert(!app.startApp("Broken"));
assert(!app.hasApp()); assert(!app.hasApp());
assert(!app.startApp("../secrets")); assert(!app.startApp("../secrets"));