diff --git a/AGENTS.md b/AGENTS.md index ff2467e..f005250 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,9 +40,11 @@ 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. +after every launch instead of fragmenting. `sys.launch(path, arg)` pushes the current +route, `sys.replace(path, arg)` does not, and `sys.back()` pops it; history stores paths and +arguments, never Lua states. Apps 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 diff --git a/README.md b/README.md index 34503d3..b5e573d 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,13 @@ draws a fallback screen if it cannot start. 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. +(enabled by `sys.setTickInterval(ms)`). Call `sys.back()` to return to the previous route, +which the back button in the status bar does too; an empty history returns to Home. `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 +`launch` adds the current route to history; `sys.replace(path, arg)` does not. 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`. @@ -59,7 +60,7 @@ it calls `sys.setAppName("settings - wifi")`, which the status bar picks up on i | `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, arg)`, `getAppName()`, `setAppName(name)`, `getMemory()` -> `free,total`, `isClockSynced()`, `setTickInterval(ms)` | +| `sys` | `getMillis()`, `delay(ms)`, `back()`, `launch(path, arg)`, `replace(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/docs/lua-api-parity.md b/docs/lua-api-parity.md index ca087c8..8db4acb 100644 --- a/docs/lua-api-parity.md +++ b/docs/lua-api-parity.md @@ -11,10 +11,10 @@ Compared against crosspoint-reader at `src/util/lua/LuaBindings*.cpp`. `fs.listDirs`, `fs.listFiles`, `fs.exists`, `fs.readFile`, `fs.writeFile`, `gui.getWidth`, `gui.getHeight`, `gui.fillRect`, `gui.drawRect`, `gui.drawLine`, -`sys.getMillis`, `sys.delay`, `sys.exit`, `log.debug/info/error`, the whole `http` table, +`sys.getMillis`, `sys.delay`, `log.debug/info/error`, the whole `http` table, `sys.setTickInterval`, `wifi.getStatus`, `wifi.isConnected`, `wifi.getLocalIP`, and the -`init()` / `draw()` / `on_tick()` callbacks. `init()` is required in both, so a -misspelled entry point is an error rather than an app that quietly draws nothing. +`draw()` / `on_tick()` callbacks. `init()` is required in both, so a misspelled entry +point is an error rather than an app that quietly draws nothing. `wifi.getStatus()` returns `{state, ssid, ip, rssi}` in both. crosspoint returned a bare string until this was reconciled; a string had nowhere to put the address and signal @@ -26,6 +26,7 @@ strength a status screen wants. Its `state` vocabulary is a subset: crosspoint r | Area | crosspoint-reader | slate32 | Why | |---|---|---|---| +| App lifecycle | `sys.exit()`, `init()` | `sys.back()`, `sys.launch/replace(path, arg)`, `init(arg)` | slate32 apps form a reloadable route stack; only the path and optional string cross between Lua states. | | Input | `input.wasPressed(button)` and friends, 8 named buttons | `input.getTouch`, `getRawTouch`, `touched` | Different hardware. A touch panel has no button names and a button device has no coordinates. | | Drawing | `gui.drawText(font, x, y, text, color, style)`, `getTextWidth(font, text)` | `gui.drawText(text, x, y, color, bg)`, `gui.getTextWidth(text)` | crosspoint ships several fonts; this firmware has one built-in font scaled by `gui.setTextSize(n)`, and needs an opaque background colour because the panel is not e-ink. | | Refresh | `gui.refresh(mode)`, `REFRESH_FULL/HALF/FAST` | none | An LCD has no waveform modes. | diff --git a/sdcard/apps/Settings/main.lua b/sdcard/apps/Settings/main.lua index 7d69453..75cad14 100644 --- a/sdcard/apps/Settings/main.lua +++ b/sdcard/apps/Settings/main.lua @@ -133,14 +133,13 @@ function buildMenu() mode = "menu" -- No title: the status bar already names the running app. The status line under the -- grid is worth its rows, so the cards give it room rather than pushing it off screen. - local side, cols = ui.cardSide(6, MENU_PAD, MENU_GAP, message and 20 or 0) + local side, cols = ui.cardSide(5, MENU_PAD, MENU_GAP, message and 20 or 0) local cards = { card(side, "calibrate", "touch", startCalibration), card(side, "rotation", settings.getRotation() .. " deg", cycleRotation), card(side, "wifi", wifiValue(wifi.getStatus()), buildWifi), card(side, "theme", ui.themeName, cycleTheme), card(side, "timezone", zoneLabel(), cycleTimezone), - card(side, "exit", nil, sys.exit), } -- Centred left to right as a unit, like the home grid, and still top aligned. diff --git a/sdcard/lib/statusbar.lua b/sdcard/lib/statusbar.lua index d3063d5..12d37ef 100644 --- a/sdcard/lib/statusbar.lua +++ b/sdcard/lib/statusbar.lua @@ -44,7 +44,7 @@ local function drawSignal(x, y, bars, color, muted) end end --- Fills the leading square of the bar, which is the rect the firmware treats as home. +-- Fills the leading square of the bar, which is the rect the firmware treats as back. -- Drawn as a button rather than left as a hidden hit region: a target nobody can see is -- one nobody finds. local function drawHome(theme) @@ -66,7 +66,7 @@ function M.setFullscreen(on) if not on then shown = {} end end -function M.draw(home) +function M.draw(hasBack) local theme = ui.theme gui.setTextSize(1) -- panel state: the app may have left it scaled up local w = gui.getWidth() @@ -88,7 +88,7 @@ function M.draw(home) gui.fillRect(0, 0, w, BAR_H, theme.bg) gui.fillRect(0, BAR_H - 1, w, 1, theme.muted) -- a rule, so the bar reads as chrome local nameX = PAD - if home then + if hasBack then drawHome(theme) nameX = BAR_H + PAD end diff --git a/src/lua/bindings/sys.cpp b/src/lua/bindings/sys.cpp index 0683735..d261eb2 100644 --- a/src/lua/bindings/sys.cpp +++ b/src/lua/bindings/sys.cpp @@ -13,8 +13,8 @@ static int l_sys_millis(lua_State* L) { return 1; } -static int l_sys_exit(lua_State* L) { - app(L)->requestExit(); +static int l_sys_back(lua_State* L) { + app(L)->requestBack(); return 0; } @@ -23,14 +23,16 @@ static int l_sys_appName(lua_State* L) { return 1; } -static int l_sys_launch(lua_State* L) { +static int navigate(lua_State* L, bool replace) { 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(); + app(L)->requestLaunch(luaL_checkstring(L, 1), arg, replace); return 0; } +static int l_sys_launch(lua_State* L) { return navigate(L, false); } +static int l_sys_replace(lua_State* L) { return navigate(L, true); } + // 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(). @@ -100,8 +102,8 @@ void registerSys(lua_State* L) { // --- Blocks for the given time. // @param ms integer {"delay", l_sys_delay}, - // --- Ends this app and returns home. - {"exit", l_sys_exit}, + // --- Returns to the previous app, or Home when there is no history. + {"back", l_sys_back}, // --- Name of the running app, its directory name until sys.setAppName() changes it. // @return string {"getAppName", l_sys_appName}, @@ -112,6 +114,10 @@ void registerSys(lua_State* L) { // @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}, + // --- Starts another app without adding this route to history. + // @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. + {"replace", l_sys_replace}, // --- Renames the running app in the status bar. // @param name string 1 to 32 bytes. {"setAppName", l_sys_setAppName}, diff --git a/src/lua/lua_app.cpp b/src/lua/lua_app.cpp index e8895d7..a6c6499 100644 --- a/src/lua/lua_app.cpp +++ b/src/lua/lua_app.cpp @@ -107,10 +107,10 @@ bool LuaApp::takeFailure() { return value; } -bool LuaApp::load(const char* path, bool isHome, const char* arg) { +bool LuaApp::load(const char* path, bool hasBack, const char* arg) { closeState(); - this->isHome = isHome; - homeArmed = false; + this->hasBack = hasBack; + backArmed = false; // Cleared per app: the interval outlives the app that set it, so a ticking app // followed by one that never ticks would keep calling a nil global. tickIntervalMs = 0; @@ -196,7 +196,7 @@ void LuaApp::drawStatusBar() { lua_getglobal(state, "__statusbar"); lua_getfield(state, -1, "draw"); lua_remove(state, -2); - lua_pushboolean(state, !isHome); // whether to paint the home button + lua_pushboolean(state, hasBack); // whether to paint the back button tft.resetViewport(); // the bar paints in panel coordinates, the app does not if (lua_pcall(state, 1, 0, 0) != LUA_OK) { Serial.printf("[statusbar] %s\n", luaL_tolstring(state, -1, nullptr)); @@ -221,14 +221,27 @@ void LuaApp::registerBindings() { registerHttp(state); } -// Deferred: sys.exit() and fail() run inside a lua_pcall, so closing the state -// here would free the VM that is still executing. +// Deferred: navigation and fail() run inside a lua_pcall, so closing the state here +// would free the VM that is still executing. void LuaApp::requestExit() { exitRequested = true; } -void LuaApp::requestLaunch(const char* path, const char* arg) { +void LuaApp::requestBack() { + exitAction = ExitAction::Back; + requestExit(); +} + +void LuaApp::requestLaunch(const char* path, const char* arg, bool replace) { + exitAction = replace ? ExitAction::Replace : ExitAction::Launch; pendingLaunch = path; pendingArg = arg ? arg : ""; pendingArgSet = arg != nullptr; + requestExit(); +} + +LuaApp::ExitAction LuaApp::takeExitAction() { + ExitAction action = exitAction; + exitAction = ExitAction::None; + return action; } String LuaApp::takePendingLaunch() { @@ -266,12 +279,12 @@ void LuaApp::loop() { // The bar is the host's: apps never see a touch in it, and its one control acts on // release, so a press that slides off into the app cancels like any other button. if (touched && lastY < 0) { - homeArmed = homeArmed || inHomeButton(); + backArmed = backArmed || inBackButton(); touched = false; - } else if (homeArmed) { - homeArmed = false; - if (lastY < 0) { - requestExit(); + } else if (backArmed) { + backArmed = false; + if (inBackButton()) { + requestBack(); return; } } diff --git a/src/lua/lua_app.h b/src/lua/lua_app.h index 3ae9515..9aad749 100644 --- a/src/lua/lua_app.h +++ b/src/lua/lua_app.h @@ -15,10 +15,10 @@ class LuaApp { LuaApp(TFT_eSPI& tft, XPT2046_Touchscreen& touch); ~LuaApp(); - // `isHome` marks the app the host falls back to, which needs no way back to itself. + // `hasBack` controls whether the status bar offers navigation to an earlier route. // `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); + bool load(const char* path, bool hasBack = false, const char* arg = nullptr); void loop(); // What the status bar shows. Defaults to the app's directory name ("settings") until @@ -39,15 +39,19 @@ class LuaApp { // tick, so a rename or a rotation is not left sitting for most of a second. void refreshStatusBar() { nextBarMs = 0; } - // Set by sys.launch(); the host loop reads both once the app has torn down. - void requestLaunch(const char* path, const char* arg); + enum class ExitAction { None, Back, Launch, Replace }; + + // Deferred navigation: bindings and bar touches run inside loop(), so the host handles + // the route change after Lua has returned and the old state can be closed safely. + void requestBack(); + void requestLaunch(const char* path, const char* arg, bool replace = false); + ExitAction takeExitAction(); String takePendingLaunch(); String takePendingArg(); bool hasPendingArg() const { return pendingArgSet; } bool running() const { return state != nullptr && !exitRequested; } - void requestExit(); - // True once if the app died on an error rather than calling sys.exit(), so the host + // True once if the app died on an error, so the host // can leave the message on screen long enough to read. bool takeFailure(); @@ -72,6 +76,7 @@ class LuaApp { lua_State* state = nullptr; bool exitRequested = false; bool failed = false; + ExitAction exitAction = ExitAction::None; 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. @@ -80,8 +85,8 @@ class LuaApp { bool lastTouched = false; bool ignoreRelease = false; bool fullscreen = false; - bool isHome = false; - bool homeArmed = false; + bool hasBack = false; + bool backArmed = false; String appName; // One strike: a bar that failed once fails identically every second, and the serial // log is the only place anyone would see it. The strip stays reserved either way, so a @@ -112,12 +117,14 @@ class LuaApp { // and the touch offset. int16_t barInset() const { return fullscreen ? 0 : barHeight; } - // The home button is the leading square of the bar, which is what /lib/statusbar.lua + // The back button is the leading square of the bar, which is what /lib/statusbar.lua // paints into. Touches are in app space, so the bar is above y = 0. - bool inHomeButton() const { - return !isHome && lastY < 0 && lastY >= -barInset() && lastX >= 0 && lastX < barInset(); + bool inBackButton() const { + return hasBack && lastY < 0 && lastY >= -barInset() && lastX >= 0 && lastX < barInset(); } + void requestExit(); + void registerBindings(); bool callGlobal(const char* name, int nargs = 0); bool hasGlobal(const char* name); diff --git a/src/main.cpp b/src/main.cpp index e5decb8..b1e70e5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -29,12 +29,38 @@ SPIClass& sdSpi = SPI; XPT2046_Touchscreen touch(TOUCH_CS); LuaApp app(tft, touch); +struct Route { + String path; + String arg; + bool hasArg = false; +}; + +static constexpr size_t MAX_HISTORY = 8; 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; +Route currentRoute; +Route pendingRoute; +Route history[MAX_HISTORY]; +size_t historyDepth = 0; +LuaApp::ExitAction pendingAction = LuaApp::ExitAction::None; + +Route homeRoute() { + Route route; + route.path = HOME; + return route; +} + +void pushHistory(const Route& route) { + if (historyDepth == MAX_HISTORY) { + // Home remains the eventual floor; discard the oldest intermediate route. + for (size_t i = 1; i < MAX_HISTORY - 1; ++i) history[i] = history[i + 1]; + historyDepth--; + } + history[historyDepth++] = route; +} + +Route previousRoute() { + return historyDepth > 0 ? history[--historyDepth] : homeRoute(); +} // Last resort only: the UI lives in Lua, so this exists purely to explain why // nothing else could run. @@ -50,13 +76,15 @@ void fallbackScreen(const char* message) { halted = true; } -void startApp(const String& path, const char* arg) { +void startApp(const Route& route) { // 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, arg)) return; - if (path == HOME) fallbackScreen("home failed to start"); + Serial.printf("launching %s free=%u largest=%u\n", route.path.c_str(), ESP.getFreeHeap(), + ESP.getMaxAllocHeap()); + const char* arg = route.hasArg ? route.arg.c_str() : nullptr; + if (app.load(route.path.c_str(), historyDepth > 0, arg)) return; + if (route.path == HOME && historyDepth == 0) fallbackScreen("home failed to start"); } void setup() { @@ -74,7 +102,8 @@ void setup() { } settings.load(); // absent file keeps the built-in defaults net::begin(); - startApp(HOME, nullptr); + currentRoute = homeRoute(); + startApp(currentRoute); } void loop() { @@ -88,22 +117,30 @@ void loop() { if (app.running()) { app.loop(); if (!app.running()) { - nextApp = app.takePendingLaunch(); - nextArgSet = app.hasPendingArg(); - nextArg = app.takePendingArg(); + pendingAction = app.takeExitAction(); + pendingRoute.path = app.takePendingLaunch(); + pendingRoute.hasArg = app.hasPendingArg(); + pendingRoute.arg = 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); + // A failed child returns to its caller after leaving the message readable. Home has + // no caller, so its failure still reaches the last-resort screen in startApp(). + if (app.takeFailure()) { + delay(ERROR_HOLD_MS); + if (pendingAction == LuaApp::ExitAction::None) pendingAction = LuaApp::ExitAction::Back; + } - String path = nextApp.length() > 0 ? nextApp : String(HOME); - bool hasArg = nextApp.length() > 0 && nextArgSet; - String arg = nextArg; - nextApp = ""; - nextArg = ""; - nextArgSet = false; - startApp(path, hasArg ? arg.c_str() : nullptr); + if (pendingAction == LuaApp::ExitAction::Launch) { + pushHistory(currentRoute); + currentRoute = pendingRoute; + } else if (pendingAction == LuaApp::ExitAction::Replace) { + currentRoute = pendingRoute; + } else { + currentRoute = previousRoute(); + } + pendingAction = LuaApp::ExitAction::None; + pendingRoute = {}; + startApp(currentRoute); } // 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 226e7dc..ba66953 100644 --- a/stubs/slate32.lua +++ b/stubs/slate32.lua @@ -267,8 +267,8 @@ function sys.getMillis() end ---@param ms integer function sys.delay(ms) end ---- Ends this app and returns home. -function sys.exit() end +--- Returns to the previous app, or Home when there is no history. +function sys.back() end --- Name of the running app, its directory name until sys.setAppName() changes it. ---@return string @@ -283,6 +283,11 @@ function sys.setTickInterval(intervalMs) end ---@param arg string? Passed to the new app's init(); at most 256 bytes. function sys.launch(path, arg) end +--- Starts another app without adding this route to history. +---@param path string Absolute path to the app's main.lua. +---@param arg string? Passed to the new app's init(); at most 256 bytes. +function sys.replace(path, arg) end + --- Renames the running app in the status bar. ---@param name string 1 to 32 bytes. function sys.setAppName(name) end diff --git a/test/fake_device.lua b/test/fake_device.lua index 601c778..9e8b7d2 100644 --- a/test/fake_device.lua +++ b/test/fake_device.lua @@ -26,8 +26,8 @@ local device = { freeHeap = 200000, totalHeap = 320000, connected = nil, -- last wifi.connect() - exited = false, - launched = nil, -- last sys.launch(), {path, arg} + backed = false, + launched = nil, -- last sys.launch()/replace(), {path, arg, replace} saveFails = false, -- make every persisting call report failure } @@ -62,7 +62,7 @@ function device.install() sys = { getMillis = function() return device.now end, - exit = function() device.exited = true end, + back = function() device.backed = true end, getAppName = function() return device.appName end, setAppName = function(name) device.appName = name end, getMemory = function() return device.freeHeap, device.totalHeap end, @@ -71,7 +71,8 @@ function device.install() assert(ms == 0 or on_tick, "setTickInterval without on_tick") device.tickInterval = ms end, - launch = function(path, arg) device.launched = {path = path, arg = arg} end, + launch = function(path, arg) device.launched = {path = path, arg = arg, replace = false} end, + replace = function(path, arg) device.launched = {path = path, arg = arg, replace = true} end, } settings = {