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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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".
|
||||
|
||||
Reference in New Issue
Block a user