From 021b94fca28f9aa743cb353f67d0597e6407dffd Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sun, 2 Aug 2026 12:02:09 -0400 Subject: [PATCH] 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. --- AGENTS.md | 19 +++++++++++++++---- README.md | 9 +++++++-- sdcard/lib/statusbar.lua | 15 +++++++++++---- src/lua/bindings/sys.cpp | 24 ++++++++++++++++++++++-- src/lua/lua_app.cpp | 22 +++++++++++++++++++--- src/lua/lua_app.h | 18 ++++++++++++++---- src/main.cpp | 22 +++++++++++++++++----- stubs/slate32.lua | 9 +++++++-- test/fake_device.lua | 5 +++-- test/statusbar_dirty.lua | 14 ++++++++++++++ 10 files changed, 129 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1177339..5edd73d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index f1d5a9e..d49219f 100644 --- a/README.md +++ b/README.md @@ -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//?.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) | diff --git a/sdcard/lib/statusbar.lua b/sdcard/lib/statusbar.lua index 576e925..d3063d5 100644 --- a/sdcard/lib/statusbar.lua +++ b/sdcard/lib/statusbar.lua @@ -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() diff --git a/src/lua/bindings/sys.cpp b/src/lua/bindings/sys.cpp index d7a6454..7c83d87 100644 --- a/src/lua/bindings/sys.cpp +++ b/src/lua/bindings/sys.cpp @@ -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. diff --git a/src/lua/lua_app.cpp b/src/lua/lua_app.cpp index 96a449f..e8895d7 100644 --- a/src/lua/lua_app.cpp +++ b/src/lua/lua_app.cpp @@ -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); diff --git a/src/lua/lua_app.h b/src/lua/lua_app.h index 9590dd9..1d31781 100644 --- a/src/lua/lua_app.h +++ b/src/lua/lua_app.h @@ -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; diff --git a/src/main.cpp b/src/main.cpp index e9b36fa..e2100c8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 diff --git a/stubs/slate32.lua b/stubs/slate32.lua index a4d1c63..226e7dc 100644 --- a/stubs/slate32.lua +++ b/stubs/slate32.lua @@ -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. diff --git a/test/fake_device.lua b/test/fake_device.lua index e823e45..601c778 100644 --- a/test/fake_device.lua +++ b/test/fake_device.lua @@ -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 = { diff --git a/test/statusbar_dirty.lua b/test/statusbar_dirty.lua index f5694c1..72d6361 100644 --- a/test/statusbar_dirty.lua +++ b/test/statusbar_dirty.lua @@ -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"