feat(ui): add a home button to the status bar, rename launcher to home

Leaving an app was the app's own responsibility, so one that shipped without
an exit could only be escaped with a reset. The bar now paints a back button
into its leading square and the firmware treats that rect as home, acting on
release so a press sliding into the app cancels. The app it returns to is
/apps/home, which is what it is to the user.

Card grids in home and settings centre left to right as a unit.
This commit is contained in:
2026-08-01 23:13:20 -04:00
parent 6bbe192081
commit b9b620ad2b
10 changed files with 76 additions and 27 deletions
+1 -1
View File
@@ -141,7 +141,7 @@ void registerSys(lua_State* L) {
// --- Blocks for the given time.
// @param ms integer
{"delay", l_sys_delay},
// --- Ends this app and returns to the launcher.
// --- Ends this app and returns home.
{"exit", l_sys_exit},
// --- Directory name of the running app, for example "settings".
// @return string
+19 -4
View File
@@ -101,12 +101,14 @@ bool LuaApp::takeFailure() {
return value;
}
bool LuaApp::load(const char* path) {
bool LuaApp::load(const char* path, bool isHome) {
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 launcher tap may still be down; swallow that gesture's release.
// The tap that launched this app may still be down; swallow that gesture's release.
lastTouched = true;
ignoreRelease = true;
state = luaL_newstate();
@@ -177,8 +179,9 @@ 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, 0, 0, 0) != LUA_OK) {
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;
@@ -229,7 +232,19 @@ void LuaApp::loop() {
if (touched) {
TS_Point p = touch.getPoint();
mapTouch(p, lastX, lastY);
if (lastY < 0) touched = false; // the bar is the host's, and it takes no input yet
}
// 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);
+11 -2
View File
@@ -15,10 +15,11 @@ class LuaApp {
LuaApp(TFT_eSPI& tft, XPT2046_Touchscreen& touch);
~LuaApp();
bool load(const char* path);
// `isHome` marks the app the host falls back to, which needs no way back to itself.
bool load(const char* path, bool isHome = false);
void loop();
// Directory name of the running app ("launcher"), for the status bar.
// Directory name of the running app ("settings"), for the status bar.
const String& name() const { return appName; }
// Lets an app own the whole panel: no viewport, no status bar. The touch calibration
@@ -64,6 +65,8 @@ class LuaApp {
bool lastTouched = false;
bool ignoreRelease = false;
bool fullscreen = false;
bool isHome = false;
bool homeArmed = 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.
@@ -92,6 +95,12 @@ class LuaApp {
// and the touch offset.
int16_t barInset() const { return (fullscreen || barBroken) ? 0 : barHeight; }
// The home 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();
}
void registerBindings();
bool callGlobal(const char* name, int nargs = 0);
bool hasGlobal(const char* name);
+8 -8
View File
@@ -16,7 +16,7 @@ static constexpr int SD_MOSI = 23;
static constexpr int SD_MISO = 19;
static constexpr int TOUCH_CS = 33;
static const char* LAUNCHER = "/apps/launcher/main.lua";
static const char* HOME = "/apps/home/main.lua";
static constexpr uint32_t ERROR_HOLD_MS = 5000;
// Arduino's default 8KB loop stack is not enough once a TLS handshake runs inside a
@@ -42,7 +42,7 @@ void fallbackScreen(const char* message) {
tft.setTextColor(TFT_RED, TFT_WHITE);
tft.drawString(message, 10, 10);
tft.setTextColor(TFT_BLACK, TFT_WHITE);
tft.drawString("expected /apps/launcher/main.lua", 10, 30);
tft.drawString("expected /apps/home/main.lua", 10, 30);
Serial.printf("halted: %s\n", message);
halted = true;
}
@@ -52,8 +52,8 @@ void startApp(const String& path) {
tft.setRotation(settings.rotationIndex()); // apps may have rotated the frame
statusbar::apply(tft, 0);
Serial.printf("launching %s\n", path.c_str());
if (app.load(path.c_str())) return;
if (path == LAUNCHER) fallbackScreen("launcher failed to start");
if (app.load(path.c_str(), path == HOME)) return;
if (path == HOME) fallbackScreen("home failed to start");
}
void setup() {
@@ -71,7 +71,7 @@ void setup() {
}
settings.load(); // absent file keeps the built-in defaults
net::begin();
startApp(LAUNCHER);
startApp(HOME);
}
void loop() {
@@ -86,11 +86,11 @@ void loop() {
app.loop();
if (!app.running()) nextApp = app.takePendingLaunch();
} else {
// An app that died left its message on screen, and the launcher is about to paint
// over it. Serial alone is no help to anyone holding the device.
// 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(LAUNCHER);
String path = nextApp.length() > 0 ? nextApp : String(HOME);
nextApp = "";
startApp(path);
}