Files
slate32/src/lua/lua_app.cpp
T
evan 021b94fca2 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.
2026-08-02 12:02:09 -04:00

310 lines
9.4 KiB
C++

// 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 <algorithm>
#include "../settings.h"
#include "bindings.h"
extern "C" {
#include <lauxlib.h>
#include <lualib.h>
}
// 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<LuaApp**>(lua_getextraspace(L)); }
void bindApp(lua_State* L, LuaApp* owner) { *static_cast<LuaApp**>(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();
}
}