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
+5 -4
View File
@@ -37,14 +37,15 @@ docs/ lua-api-parity.md, the crosspoint-reader comparison
```
Copy `sdcard/` to the SD card root: apps live in `/apps/<name>/main.lua` and shared
Lua modules in `/lib`. The launcher is itself an app (`/apps/launcher/main.lua`); the
firmware only draws a fallback screen if it cannot start.
Lua modules in `/lib`. Home is itself an app (`/apps/home/main.lua`); the firmware only
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)`,
`on_touch_up(x, y)`, `on_touch(x, y)` (tap alias, fired on release), and `on_tick()`
(enabled by `app.setTickInterval(ms)`). Call `sys.exit()` to return to the launcher.
(enabled by `sys.setTickInterval(ms)`). Call `sys.exit()` to return home, which the back
button in the status bar does too.
`require` reads from the SD card: `/apps/<name>/?.lua` first, then `/lib/?.lua`.
@@ -85,7 +86,7 @@ card like any other device containing a saved password.
Rotation never needs a recalibration: calibration is stored in the panel's
rotation-0 frame (320x480 raw ADC space) and the current rotation is applied
afterwards, so `sys.setRotation()` is safe at any time. `gui.setRotation(0-3)`
changes only the current frame; the launcher restores the saved rotation when an
changes only the current frame; the firmware restores the saved rotation when an
app exits. The glass itself is always portrait, so a rotated UI is drawn
sideways on it.
+1 -1
View File
@@ -34,7 +34,7 @@ strength a status screen wants. Its `state` vocabulary is a subset: crosspoint r
| Themes | none | `sys.getTheme/setTheme`, `/lib/theme.lua` | Colour panel. |
| Rotation | `gui.setOrientation("portrait")` | `sys.setRotation(degrees)` persisted, `gui.setRotation(0-3)` for one frame | This device stores rotation in settings and remaps touch to match. |
| Clock | nothing exposed; UTC offset is a C++ setting | `sys.clockSynced`, `sys.getTimezone/setTimezone` with POSIX TZ rules | Timezone here is a stored rule, so `os.date()` returns local time with DST handled by libc. |
| Launching | launcher is C++ | `sys.launch(path)`, launcher is a Lua app | The launcher is just another app here. |
| Launching | launcher is C++ | `sys.launch(path)`, home is a Lua app | The launcher is just another app here, named `home`. |
| Modules | single-file apps; `require` unusable | `require` works, `package.searchers` reads the SD card, `/lib` on the path | Shared code such as `ui.lua` needs it. **crosspoint should adopt this.** |
| BLE | `ble.*` | none | No BLE use case here yet. |
| TLS memory | `TlsScratchLoan` lends the framebuffer to wolfSSL | none | crosspoint is heap-starved; this device has ~280KB free. |
@@ -17,7 +17,7 @@ end
function init()
local names = {}
for _, name in ipairs(fs.listDirs("/apps")) do
if name ~= "launcher" then names[#names + 1] = name end
if name ~= "home" then names[#names + 1] = name end
end
table.sort(names)
@@ -35,15 +35,18 @@ function init()
row[#row + 1] = card(name, side)
end
local items = {pad = PAD, gap = GAP}
-- Centred left to right as a unit, still top aligned: the gaps inside the grid are
-- fixed, so the leftover width belongs beside the block, not to its last column.
local items = {pad = PAD, gap = GAP, w = "fill", align = "center"}
for _, row in ipairs(rows) do
row.row, row.gap = true, GAP
row.w = #row * side + (#row - 1) * GAP
items[#items + 1] = ui.box(row)
end
if #rows == 0 then items[#items + 1] = ui.text("no apps in /apps", {color = ui.theme.muted}) end
screen = ui.screen(ui.box(items))
log.info("launcher ready")
log.info("home ready")
end
function draw() screen:draw() end
+3 -1
View File
@@ -141,10 +141,12 @@ function buildMenu()
card(side, "exit", nil, sys.exit),
}
local items = {pad = MENU_PAD, gap = MENU_GAP}
-- Centred left to right as a unit, like the home grid, and still top aligned.
local items = {pad = MENU_PAD, gap = MENU_GAP, w = "fill", align = "center"}
for index = 1, #cards, cols do
local row = {row = true, gap = MENU_GAP}
for column = index, math.min(index + cols - 1, #cards) do row[#row + 1] = cards[column] end
row.w = #row * side + (#row - 1) * MENU_GAP
items[#items + 1] = ui.box(row)
end
if message then items[#items + 1] = ui.text(message) end
+21 -2
View File
@@ -28,7 +28,21 @@ local function drawSignal(x, y, color, muted)
end
end
function M.draw()
-- Fills the leading square of the bar, which is the rect the firmware treats as home.
-- 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)
local inset = 3
local side = BAR_H - 1 - inset * 2
gui.roundRect(inset, inset, side, side, 4, theme.bg, theme.face[1], theme.face[2], theme.muted)
local x, y = inset + side // 2 + 1, inset + side // 2
for offset = 0, 1 do -- two passes, because a one pixel chevron reads as a speck
gui.drawLine(x + offset, y - 4, x + offset - 4, y, theme.fg)
gui.drawLine(x + offset - 4, y, x + offset, y + 4, theme.fg)
end
end
function M.draw(home)
local theme = ui.theme
gui.setTextSize(1) -- panel state: the app may have left it scaled up
local w = gui.width()
@@ -36,7 +50,12 @@ function M.draw()
gui.fillRect(0, BAR_H - 1, w, 1, theme.muted) -- a rule, so the bar reads as chrome
local textY = math.floor((BAR_H - 1 - gui.fontHeight()) / 2)
gui.drawText(sys.appName(), PAD, textY, theme.fg, theme.bg)
local nameX = PAD
if home then
drawHome(theme)
nameX = BAR_H + PAD
end
gui.drawText(sys.appName(), nameX, textY, theme.fg, theme.bg)
local time = clock()
gui.drawText(time, w - PAD - gui.textWidth(time), textY, theme.muted, theme.bg)
+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);
}
+1 -1
View File
@@ -225,7 +225,7 @@ function sys.millis() end
---@param ms integer
function sys.delay(ms) end
--- Ends this app and returns to the launcher.
--- Ends this app and returns home.
function sys.exit() end
--- Directory name of the running app, for example "settings".