feat(lua): pass an argument to init() and let apps name themselves
sys.launch(path, arg) carries a string to the next app's init(arg), nil when there is none. States share no memory, so one string is the whole handoff; anything structured travels as a Lua literal the receiver loads. This is what a screen split across apps needs to say "collect a password for this network". sys.setAppName() retitles the status bar, defaulting to the directory name as before. A setter rather than a declared constant, so one app can retitle per screen. The bar needs no new invalidation path for it -- the name joins rotation and theme in the cache key -- and clips a name wide enough to reach the memory slot.
This commit is contained in:
@@ -37,12 +37,20 @@ Settings live in C++ (`src/settings.h`) because the firmware reads rotation and
|
||||
before any `lua_State` exists, and calibration again on every touch. Lua reaches them through
|
||||
bindings so there is one writer.
|
||||
|
||||
## Apps
|
||||
|
||||
One `lua_State` per app, closed on exit, which is why the heap returns to the same shape
|
||||
after every launch instead of fragmenting. Apps hand off with `sys.launch(path, arg)` and
|
||||
receive the string as `init(arg)` -- states share no memory, so a string is the whole
|
||||
handoff. `sys.setAppName()` retitles the bar for a screen within an app.
|
||||
|
||||
## Status Bar
|
||||
|
||||
`sdcard/lib/statusbar.lua` paints the top strip; the firmware only clips apps out of it and
|
||||
calls `draw(home)` on a timer. **Invalidation is entirely Lua's**: `draw()` compares each
|
||||
field against what it last painted and keys the whole cache on `gui.getRotation()` and
|
||||
`ui.themeName`. A new app gets a fresh `lua_State`, so an empty cache already means "repaint
|
||||
field against what it last painted and keys the whole cache on `gui.getRotation()`,
|
||||
`ui.themeName` and `sys.getAppName()`. Adding a field that can change means adding a term
|
||||
to that key, never a flag on the C++ side. A new app gets a fresh `lua_State`, so an empty cache already means "repaint
|
||||
everything".
|
||||
|
||||
The one change Lua cannot observe is an app that painted over the bar while fullscreen —
|
||||
@@ -55,8 +63,11 @@ Full instructions in `.pi/skills/test-e32r40t-firmware/SKILL.md`. Two things tha
|
||||
|
||||
- The launcher logs `[lua] home ready`, not "launcher ready". It is `/apps/home`, an app like
|
||||
any other.
|
||||
- `wait-frame` is an e-ink command and hangs on this board. Use `wait-idle SECONDS` between
|
||||
captures, and `wait-log` for everything semantic.
|
||||
- `wait-frame` is an e-ink command and hangs on this board, and `sleep` is not a command at
|
||||
all -- a script using either stops silently. Use `wait-idle SECONDS` between captures and
|
||||
`wait-log` for everything semantic.
|
||||
- Captures round-trip slower than a fast UI transition, so a screen that appears for under a
|
||||
second is unreliable to photograph. Assert those on the host instead.
|
||||
|
||||
Pure Lua logic belongs in `test/*.lua` against `test/fake_device.lua`, which is the single
|
||||
definition of the binding surface for host tests. Renaming a binding means editing that file.
|
||||
|
||||
@@ -42,11 +42,16 @@ draws a fallback screen if it cannot start.
|
||||
|
||||
## Lua API
|
||||
|
||||
Apps define `init()`, which is required, plus optional `draw()` (~30fps cap), `on_touch_down(x, y)`,
|
||||
Apps define `init(arg)`, which is required, plus optional `draw()` (~30fps cap), `on_touch_down(x, y)`,
|
||||
`on_touch_up(x, y)`, `on_touch(x, y)` (tap alias, fired on release), and `on_tick()`
|
||||
(enabled by `sys.setTickInterval(ms)`). Call `sys.exit()` to return home, which the back
|
||||
button in the status bar does too.
|
||||
|
||||
`init` receives whatever string `sys.launch(path, arg)` passed, or `nil` from the launcher.
|
||||
States share no memory, so one string is the whole handoff; anything structured travels as
|
||||
a Lua literal the receiver runs through `load()`. An app is named after its directory until
|
||||
it calls `sys.setAppName("settings - wifi")`, which the status bar picks up on its next tick.
|
||||
|
||||
`require` reads from the SD card: `/apps/<name>/?.lua` first, then `/lib/?.lua`.
|
||||
|
||||
| Module | Functions |
|
||||
@@ -54,7 +59,7 @@ button in the status bar does too.
|
||||
| `gui` | `getWidth()`, `getHeight()`, `clear(color)`, `fillRect(x,y,w,h,c)`, `drawRect(x,y,w,h,c)`, `fillCircle(x,y,r,c,bg)`, `drawLine(x1,y1,x2,y2,c)`, `drawText(text,x,y,fg,bg)`, `roundRect(x,y,w,h,radius,bg,top,bottom,border)`, `getFontHeight()`, `getTextWidth(text)`, `getRotation()`, `setRotation(deg)`, `setFullscreen(on)`, `color(r,g,b)` |
|
||||
| `input` | `getTouch()` -> `x,y` or nil, `getRawTouch()` -> raw ADC `x,y` or nil, `isTouched()` |
|
||||
| `fs` | `readFile(path)`, `writeFile(path, data)`, `exists(path)`, `listFiles(path)`, `listDirs(path)` |
|
||||
| `sys` | `getMillis()`, `delay(ms)`, `exit()`, `launch(path)`, `getAppName()`, `getMemory()` -> `free,total`, `isClockSynced()`, `setTickInterval(ms)` |
|
||||
| `sys` | `getMillis()`, `delay(ms)`, `exit()`, `launch(path, arg)`, `getAppName()`, `setAppName(name)`, `getMemory()` -> `free,total`, `isClockSynced()`, `setTickInterval(ms)` |
|
||||
| `settings` | `getRotation()`, `setRotation(deg)`, `getTheme()`, `setTheme(name)`, `getTimezone()`, `setTimezone(tz)`, `setCalibration(x0,y0,x1,y1)` |
|
||||
| `wifi` | `scan()` -> `{ssid,rssi,secure}[]`, `connect(ssid,password)`, `getStatus()` -> `{state,ssid,ip,rssi}`, `getLocalIP()`, `isConnected()`, `disconnect()`, `forget()` |
|
||||
| `log` | `debug(msg)`, `info(msg)`, `error(msg)` (serial) |
|
||||
|
||||
@@ -78,9 +78,11 @@ function M.draw(home)
|
||||
local signalX = clockX - GAP - SIGNAL_W
|
||||
local memX = signalX - GAP - memW
|
||||
|
||||
-- Rotation moves every slot and the theme recolors them; a new app gets a fresh Lua
|
||||
-- state, so an empty cache already means "repaint everything".
|
||||
local key = gui.getRotation() .. "|" .. ui.themeName
|
||||
-- Rotation moves every slot, the theme recolors them, and an app may rename itself at
|
||||
-- any time. A new app gets a fresh Lua state, so an empty cache already means "repaint
|
||||
-- everything".
|
||||
local name = sys.getAppName()
|
||||
local key = gui.getRotation() .. "|" .. ui.themeName .. "|" .. name
|
||||
if key ~= shown.key then
|
||||
shown = {key = key}
|
||||
gui.fillRect(0, 0, w, BAR_H, theme.bg)
|
||||
@@ -90,7 +92,12 @@ function M.draw(home)
|
||||
drawHome(theme)
|
||||
nameX = BAR_H + PAD
|
||||
end
|
||||
gui.drawText(sys.getAppName(), nameX, textY, theme.fg, theme.bg)
|
||||
-- Clipped rather than overrun: the name is the one field an app controls, and the
|
||||
-- memory slot sits directly after it.
|
||||
while #name > 1 and nameX + gui.getTextWidth(name) > memX - GAP do
|
||||
name = name:sub(1, -2)
|
||||
end
|
||||
gui.drawText(name, nameX, textY, theme.fg, theme.bg)
|
||||
end
|
||||
|
||||
local mem = memPercent()
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
#include "../bindings.h"
|
||||
#include "../lua_app.h"
|
||||
|
||||
static constexpr size_t MAX_APP_NAME = 32;
|
||||
static constexpr size_t MAX_LAUNCH_ARG = 256;
|
||||
|
||||
static int l_sys_millis(lua_State* L) {
|
||||
lua_pushinteger(L, millis());
|
||||
return 1;
|
||||
@@ -21,11 +24,24 @@ static int l_sys_appName(lua_State* L) {
|
||||
}
|
||||
|
||||
static int l_sys_launch(lua_State* L) {
|
||||
app(L)->requestLaunch(luaL_checkstring(L, 1));
|
||||
const char* arg = lua_isnoneornil(L, 2) ? nullptr : luaL_checkstring(L, 2);
|
||||
if (arg && strlen(arg) > MAX_LAUNCH_ARG) return luaL_error(L, "launch argument too long");
|
||||
app(L)->requestLaunch(luaL_checkstring(L, 1), arg);
|
||||
app(L)->requestExit();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Renames the running app for the status bar, which is the only thing that reads it. Kept
|
||||
// as a setter rather than a constant the chunk declares: a screen that retitles itself
|
||||
// ("settings" to "settings - wifi") needs it to change after init().
|
||||
static int l_sys_setAppName(lua_State* L) {
|
||||
size_t length;
|
||||
const char* name = luaL_checklstring(L, 1, &length);
|
||||
if (!length || length > MAX_APP_NAME) return luaL_error(L, "app name must be 1 to 32 bytes");
|
||||
app(L)->setName(String(name, length));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Lua's os.time()/os.date() work off the same system clock, so this is the only
|
||||
// binding a clock UI needs: it says whether that clock means anything yet.
|
||||
static int l_sys_clockSynced(lua_State* L) {
|
||||
@@ -85,7 +101,7 @@ void registerSys(lua_State* L) {
|
||||
{"delay", l_sys_delay},
|
||||
// --- Ends this app and returns home.
|
||||
{"exit", l_sys_exit},
|
||||
// --- Directory name of the running app, for example "settings".
|
||||
// --- Name of the running app, its directory name until sys.setAppName() changes it.
|
||||
// @return string
|
||||
{"getAppName", l_sys_appName},
|
||||
// --- Sets how often on_tick() runs. Errors when on_tick is not defined.
|
||||
@@ -93,7 +109,11 @@ void registerSys(lua_State* L) {
|
||||
{"setTickInterval", l_sys_setTickInterval},
|
||||
// --- Ends this app and starts another one.
|
||||
// @param path string Absolute path to the app's main.lua.
|
||||
// @param arg string|nil Passed to the new app's init(); at most 256 bytes.
|
||||
{"launch", l_sys_launch},
|
||||
// --- Renames the running app in the status bar.
|
||||
// @param name string 1 to 32 bytes.
|
||||
{"setAppName", l_sys_setAppName},
|
||||
// --- Free and total heap, in bytes.
|
||||
// @return integer Free bytes.
|
||||
// @return integer Total bytes.
|
||||
|
||||
+19
-3
@@ -107,7 +107,7 @@ bool LuaApp::takeFailure() {
|
||||
return value;
|
||||
}
|
||||
|
||||
bool LuaApp::load(const char* path, bool isHome) {
|
||||
bool LuaApp::load(const char* path, bool isHome, const char* arg) {
|
||||
closeState();
|
||||
this->isHome = isHome;
|
||||
homeArmed = false;
|
||||
@@ -149,7 +149,12 @@ bool LuaApp::load(const char* path, bool isHome) {
|
||||
return false;
|
||||
}
|
||||
lua_getglobal(state, "init");
|
||||
if (!callGlobal("init")) return false;
|
||||
if (arg) {
|
||||
lua_pushstring(state, arg);
|
||||
} else {
|
||||
lua_pushnil(state); // pushed either way, so init(arg) reads the same in both cases
|
||||
}
|
||||
if (!callGlobal("init", 1)) return false;
|
||||
return running();
|
||||
}
|
||||
|
||||
@@ -220,7 +225,11 @@ void LuaApp::registerBindings() {
|
||||
// here would free the VM that is still executing.
|
||||
void LuaApp::requestExit() { exitRequested = true; }
|
||||
|
||||
void LuaApp::requestLaunch(const char* path) { pendingLaunch = path; }
|
||||
void LuaApp::requestLaunch(const char* path, const char* arg) {
|
||||
pendingLaunch = path;
|
||||
pendingArg = arg ? arg : "";
|
||||
pendingArgSet = arg != nullptr;
|
||||
}
|
||||
|
||||
String LuaApp::takePendingLaunch() {
|
||||
String path = pendingLaunch;
|
||||
@@ -228,6 +237,13 @@ String LuaApp::takePendingLaunch() {
|
||||
return path;
|
||||
}
|
||||
|
||||
String LuaApp::takePendingArg() {
|
||||
String arg = pendingArg;
|
||||
pendingArg = "";
|
||||
pendingArgSet = false;
|
||||
return arg;
|
||||
}
|
||||
|
||||
void LuaApp::closeState() {
|
||||
if (state) {
|
||||
lua_close(state);
|
||||
|
||||
+14
-4
@@ -16,11 +16,15 @@ class LuaApp {
|
||||
~LuaApp();
|
||||
|
||||
// `isHome` marks the app the host falls back to, which needs no way back to itself.
|
||||
bool load(const char* path, bool isHome = false);
|
||||
// `arg` reaches the app as init()'s only parameter, or nil when there is none: states
|
||||
// share no memory, so a string is the whole handoff between one app and the next.
|
||||
bool load(const char* path, bool isHome = false, const char* arg = nullptr);
|
||||
void loop();
|
||||
|
||||
// Directory name of the running app ("settings"), for the status bar.
|
||||
// What the status bar shows. Defaults to the app's directory name ("settings") until
|
||||
// the app renames itself with sys.setAppName().
|
||||
const String& name() const { return appName; }
|
||||
void setName(const String& name) { appName = name; }
|
||||
|
||||
// Lets an app own the whole panel: no viewport, no status bar. The touch calibration
|
||||
// needs the physical edges; almost nothing else should.
|
||||
@@ -30,9 +34,11 @@ class LuaApp {
|
||||
// leaves the previous viewport metrics behind.
|
||||
void applyViewport();
|
||||
|
||||
// Set by sys.launch(); the host loop reads it once the app has torn down.
|
||||
void requestLaunch(const char* path);
|
||||
// Set by sys.launch(); the host loop reads both once the app has torn down.
|
||||
void requestLaunch(const char* path, const char* arg);
|
||||
String takePendingLaunch();
|
||||
String takePendingArg();
|
||||
bool hasPendingArg() const { return pendingArgSet; }
|
||||
bool running() const { return state != nullptr && !exitRequested; }
|
||||
void requestExit();
|
||||
|
||||
@@ -62,6 +68,10 @@ class LuaApp {
|
||||
bool exitRequested = false;
|
||||
bool failed = false;
|
||||
String pendingLaunch;
|
||||
// Tracked apart from the string: an app may legitimately pass "", which is not the same
|
||||
// as being launched with no argument at all.
|
||||
String pendingArg;
|
||||
bool pendingArgSet = false;
|
||||
bool lastTouched = false;
|
||||
bool ignoreRelease = false;
|
||||
bool fullscreen = false;
|
||||
|
||||
+17
-5
@@ -31,6 +31,10 @@ LuaApp app(tft, touch);
|
||||
|
||||
bool halted = false;
|
||||
String nextApp;
|
||||
// Carried beside the path, because sys.launch()'s argument has to outlive the state that
|
||||
// passed it: the sender is torn down before the receiver exists.
|
||||
String nextArg;
|
||||
bool nextArgSet = false;
|
||||
|
||||
// Last resort only: the UI lives in Lua, so this exists purely to explain why
|
||||
// nothing else could run.
|
||||
@@ -46,12 +50,12 @@ void fallbackScreen(const char* message) {
|
||||
halted = true;
|
||||
}
|
||||
|
||||
void startApp(const String& path) {
|
||||
void startApp(const String& path, const char* arg) {
|
||||
// Full panel until the app's own status bar module reports its height in load().
|
||||
tft.setRotation(settings.rotationIndex()); // apps may have rotated the frame
|
||||
tft.resetViewport(); // load() re-clips once the new app's bar reports its height
|
||||
Serial.printf("launching %s free=%u largest=%u\n", path.c_str(), ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
if (app.load(path.c_str(), path == HOME)) return;
|
||||
if (app.load(path.c_str(), path == HOME, arg)) return;
|
||||
if (path == HOME) fallbackScreen("home failed to start");
|
||||
}
|
||||
|
||||
@@ -70,7 +74,7 @@ void setup() {
|
||||
}
|
||||
settings.load(); // absent file keeps the built-in defaults
|
||||
net::begin();
|
||||
startApp(HOME);
|
||||
startApp(HOME, nullptr);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
@@ -83,15 +87,23 @@ void loop() {
|
||||
|
||||
if (app.running()) {
|
||||
app.loop();
|
||||
if (!app.running()) nextApp = app.takePendingLaunch();
|
||||
if (!app.running()) {
|
||||
nextApp = app.takePendingLaunch();
|
||||
nextArgSet = app.hasPendingArg();
|
||||
nextArg = app.takePendingArg();
|
||||
}
|
||||
} else {
|
||||
// An app that died left its message on screen, and home is about to paint over it.
|
||||
// Serial alone is no help to anyone holding the device.
|
||||
if (app.takeFailure()) delay(ERROR_HOLD_MS);
|
||||
|
||||
String path = nextApp.length() > 0 ? nextApp : String(HOME);
|
||||
bool hasArg = nextApp.length() > 0 && nextArgSet;
|
||||
String arg = nextArg;
|
||||
nextApp = "";
|
||||
startApp(path);
|
||||
nextArg = "";
|
||||
nextArgSet = false;
|
||||
startApp(path, hasArg ? arg.c_str() : nullptr);
|
||||
}
|
||||
|
||||
// Hands the core to the idle task instead of spinning between polls: touch reads are
|
||||
|
||||
+7
-2
@@ -270,7 +270,7 @@ function sys.delay(ms) end
|
||||
--- Ends this app and returns home.
|
||||
function sys.exit() end
|
||||
|
||||
--- Directory name of the running app, for example "settings".
|
||||
--- Name of the running app, its directory name until sys.setAppName() changes it.
|
||||
---@return string
|
||||
function sys.getAppName() end
|
||||
|
||||
@@ -280,7 +280,12 @@ function sys.setTickInterval(intervalMs) end
|
||||
|
||||
--- Ends this app and starts another one.
|
||||
---@param path string Absolute path to the app's main.lua.
|
||||
function sys.launch(path) end
|
||||
---@param arg string? Passed to the new app's init(); at most 256 bytes.
|
||||
function sys.launch(path, arg) end
|
||||
|
||||
--- Renames the running app in the status bar.
|
||||
---@param name string 1 to 32 bytes.
|
||||
function sys.setAppName(name) end
|
||||
|
||||
--- Free and total heap, in bytes.
|
||||
---@return integer Free bytes.
|
||||
|
||||
@@ -27,7 +27,7 @@ local device = {
|
||||
totalHeap = 320000,
|
||||
connected = nil, -- last wifi.connect()
|
||||
exited = false,
|
||||
launched = nil,
|
||||
launched = nil, -- last sys.launch(), {path, arg}
|
||||
saveFails = false, -- make every persisting call report failure
|
||||
}
|
||||
|
||||
@@ -64,13 +64,14 @@ function device.install()
|
||||
getMillis = function() return device.now end,
|
||||
exit = function() device.exited = true end,
|
||||
getAppName = function() return device.appName end,
|
||||
setAppName = function(name) device.appName = name end,
|
||||
getMemory = function() return device.freeHeap, device.totalHeap 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) device.launched = path end,
|
||||
launch = function(path, arg) device.launched = {path = path, arg = arg} end,
|
||||
}
|
||||
|
||||
settings = {
|
||||
|
||||
@@ -47,6 +47,20 @@ device.theme = "dark"
|
||||
ui.reloadTheme()
|
||||
assert(has(paint(), device.appName), "a theme change repaints the whole bar")
|
||||
|
||||
-- An app renaming itself is a bar change like any other, with no invalidation call.
|
||||
sys.setAppName("settings - wifi")
|
||||
assert(has(paint(), "settings - wifi"), "a renamed app repaints the whole bar")
|
||||
assert(#paint() == 0, "and settles again afterwards")
|
||||
|
||||
-- A name wide enough to reach the memory slot is clipped, not drawn over it.
|
||||
sys.setAppName(string.rep("w", 60))
|
||||
local clipped
|
||||
for _, label in ipairs(paint()) do
|
||||
if label:sub(1, 1) == "w" then clipped = label end
|
||||
end
|
||||
assert(clipped and #clipped < 60, "an over-long name is clipped, got " .. #(clipped or ""))
|
||||
sys.setAppName("test")
|
||||
|
||||
-- Leaving fullscreen is the one change draw() cannot see: the app painted over the bar
|
||||
-- while nothing about the bar's own state moved.
|
||||
device.theme = "light"
|
||||
|
||||
Reference in New Issue
Block a user