// App lifecycle: own the lua_State, dispatch the callbacks, and tear down safely. // The bindings themselves live in bindings/, the loader in module_loader.cpp. #include "lua_app.h" #include #include "../settings.h" #include "bindings.h" extern "C" { #include #include } // LUA_EXTRASPACE is a pointer-sized block ahead of every lua_State, and Lua copies it // into new coroutines, so the owner is reachable from any state without a global. LuaApp* app(lua_State* L) { return *static_cast(lua_getextraspace(L)); } void bindApp(lua_State* L, LuaApp* owner) { *static_cast(lua_getextraspace(L)) = owner; } void LuaApp::mapTouch(const TS_Point& p, int16_t& x, int16_t& y) const { int16_t nx = constrain(map(p.y, settings.touchX0, settings.touchX1, 0, PANEL_W), 0, PANEL_W - 1); int16_t ny = constrain(map(p.x, settings.touchY0, settings.touchY1, 0, PANEL_H), 0, PANEL_H - 1); switch (tft.getRotation() & 3) { case 1: x = ny; y = PANEL_W - 1 - nx; break; case 2: x = PANEL_W - 1 - nx; y = PANEL_H - 1 - ny; break; case 3: x = PANEL_H - 1 - ny; y = nx; break; default: x = nx; y = ny; break; } // App space starts below the bar, matching the viewport apps draw into. A tap on the // bar itself lands at a negative y, which loop() drops. y -= barInset(); } // resetViewport() first: width()/height() report the *viewport* once one is set, so // re-applying over an existing one would shrink the app area again every time. // setRotation() leaves the old viewport metrics behind, so every rotation needs this too. void LuaApp::applyViewport() { int16_t inset = barInset(); tft.resetViewport(); if (inset > 0) tft.setViewport(0, inset, tft.width(), tft.height() - inset, true); } void LuaApp::setFullscreen(bool on) { fullscreen = on; applyViewport(); nextBarMs = 0; // leaving fullscreen left the bar's rows painted by the app } LuaApp::LuaApp(TFT_eSPI& tft, XPT2046_Touchscreen& touch) : tft(tft), touch(touch) {} LuaApp::~LuaApp() { if (state) lua_close(state); } bool LuaApp::hasGlobal(const char* name) { lua_getglobal(state, name); bool found = lua_isfunction(state, -1); lua_pop(state, 1); return found; } bool LuaApp::callGlobal(const char* name, int nargs) { if (lua_pcall(state, nargs, 0, 0) != LUA_OK) { fail(lua_tostring(state, -1)); return false; } return true; } void LuaApp::fireTouch(const char* name, int16_t x, int16_t y) { if (!hasGlobal(name)) return; lua_getglobal(state, name); lua_pushinteger(state, x); lua_pushinteger(state, y); callGlobal(name, 2); } // Deliberately not themed: this can fire when the settings naming the theme are // themselves unreadable, so it stays high contrast. void LuaApp::fail(const char* message) { Serial.printf("[lua] error: %s\n", message ? message : "(unknown)"); tft.fillScreen(TFT_WHITE); tft.setTextColor(TFT_RED, TFT_WHITE); tft.drawString("Lua error:", 8, 8); tft.drawString(message ? message : "(unknown)", 8, 28); failed = true; requestExit(); } bool LuaApp::takeFailure() { bool value = failed; failed = false; return value; } bool LuaApp::load(const char* path, bool isHome, const char* arg) { closeState(); this->isHome = isHome; homeArmed = 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; // The tap that launched this app may still be down; swallow that gesture's release. lastTouched = true; ignoreRelease = true; state = luaL_newstate(); if (!state) return false; bindApp(state, this); luaL_openlibs(state); registerBindings(); String appDir = path; int slash = appDir.lastIndexOf('/'); appDir = slash > 0 ? appDir.substring(0, slash) : String("/"); installLoader(appDir.c_str()); appName = appDir.substring(appDir.lastIndexOf('/') + 1); // Before the chunk runs: the bar's height sets the viewport, and an app measuring // gui.height() at the top of init() has to see the area it actually owns. loadStatusBar(); applyViewport(); // Painted before the app's chunk runs, not on the next bar tick: loading a script off // the SD card takes long enough that the previous app's name would linger visibly. if (barInset() > 0 && !barBroken) drawStatusBar(); if (loadScript(state, path) != LUA_OK) { fail(lua_tostring(state, -1)); return false; } if (!callGlobal(path)) return false; // run chunk body // An app whose entry point is misspelled would otherwise start, draw nothing, and // give no hint why. if (!hasGlobal("init")) { fail("Missing init()"); return false; } lua_getglobal(state, "init"); 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(); } // Reads an optional positive integer field off the bar module on the stack top. static lua_Integer barField(lua_State* L, const char* key, lua_Integer fallback) { lua_getfield(L, -1, key); lua_Integer value = lua_isinteger(L, -1) ? lua_tointeger(L, -1) : fallback; lua_pop(L, 1); return value > 0 ? value : fallback; } // The bar is a Lua module like any other, loaded per app because the state is too. Its // own table owns the geometry and the repaint rate: sys.setTickInterval() belongs to the // app, and there is only one of those per state. void LuaApp::loadStatusBar() { barBroken = false; barHeight = DEFAULT_BAR_H; barIntervalMs = BAR_INTERVAL_MS; lua_getglobal(state, "require"); lua_pushstring(state, "statusbar"); if (lua_pcall(state, 1, 1, 0) != LUA_OK || !lua_istable(state, -1)) { Serial.printf("[statusbar] unavailable: %s\n", luaL_tolstring(state, -1, nullptr)); // Nothing has measured the panel yet, so surrendering the strip here is free. A bar // that dies later keeps its rows, because the app has already laid itself out. barBroken = true; barHeight = 0; lua_pop(state, 2); return; } barHeight = barField(state, "height", DEFAULT_BAR_H); barIntervalMs = std::max((lua_Integer)MIN_TICK_MS, barField(state, "interval", BAR_INTERVAL_MS)); lua_setglobal(state, "__statusbar"); nextBarMs = 0; } // Errors here are logged and disable the bar rather than killing the app: chrome that // fails should not take the running program with it. 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 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)); lua_pop(state, 2); barBroken = true; } applyViewport(); } void LuaApp::setTickInterval(uint32_t intervalMs) { tickIntervalMs = intervalMs; nextTickMs = millis() + intervalMs; } void LuaApp::registerBindings() { registerGui(state); registerSys(state); registerSettings(state); registerInput(state); registerFs(state); registerWifi(state); 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. void LuaApp::requestExit() { exitRequested = true; } void LuaApp::requestLaunch(const char* path, const char* arg) { pendingLaunch = path; pendingArg = arg ? arg : ""; pendingArgSet = arg != nullptr; } String LuaApp::takePendingLaunch() { String path = pendingLaunch; pendingLaunch = ""; return path; } String LuaApp::takePendingArg() { String arg = pendingArg; pendingArg = ""; pendingArgSet = false; return arg; } void LuaApp::closeState() { if (state) { lua_close(state); state = nullptr; } exitRequested = false; } void LuaApp::loop() { if (exitRequested) closeState(); if (!state) return; uint32_t now = millis(); bool touched = touch.touched(); if (touched) { TS_Point p = touch.getPoint(); mapTouch(p, lastX, lastY); } // 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(); touched = false; } else if (homeArmed) { homeArmed = false; if (lastY < 0) { requestExit(); return; } } if (touched && !lastTouched) { fireTouch("on_touch_down", lastX, lastY); if (!running()) return; } else if (!touched && lastTouched && ignoreRelease) { ignoreRelease = false; // the press that launched this app is not its own gesture } else if (!touched && lastTouched) { fireTouch("on_touch_up", lastX, lastY); if (!running()) return; fireTouch("on_touch", lastX, lastY); // tap alias, fired on release like a click if (!running()) return; } lastTouched = touched; if (tickIntervalMs > 0 && now >= nextTickMs) { nextTickMs = now + tickIntervalMs; lua_getglobal(state, "on_tick"); callGlobal("on_tick"); if (!running()) return; } if (now >= nextDrawMs && hasGlobal("draw")) { nextDrawMs = now + DRAW_INTERVAL_MS; lua_getglobal(state, "draw"); callGlobal("draw"); if (!running()) return; } if (barInset() > 0 && !barBroken && now >= nextBarMs) { nextBarMs = now + barIntervalMs; drawStatusBar(); } }