diff --git a/.pi/skills/test-e32r40t-firmware/SKILL.md b/.pi/skills/test-e32r40t-firmware/SKILL.md index 143c01d..bb129ad 100644 --- a/.pi/skills/test-e32r40t-firmware/SKILL.md +++ b/.pi/skills/test-e32r40t-firmware/SKILL.md @@ -75,8 +75,10 @@ entirely. Wait for the app's own ready log, then `sleep` a few seconds. `tap` always takes physical panel pixels (320x480 portrait), because the glass never rotates. Screens are laid out by `/lib/ui.lua`, so read tap targets off a `capture` rather than -computing them. For the default launcher and settings styling (`pad = 12`, `gap = 8`, a -title 8px tall, buttons 24px tall) rows land at y=28, 60 and 92. +computing them. The status bar (`/lib/statusbar.lua`) takes the top 22 rows in every app, +so for the default launcher and settings styling (`pad = 12`, `gap = 8`, a title 8px tall, +buttons 24px tall) rows land at y=62, 96 and 128. Taps that land within the first few +seconds after a ready log are missed, so `wait-idle 3` before the first one. Use `touch-hold` plus `capture` plus `touch-release` to photograph a pressed button: `on_press` fires on release, and the pressed style is only visible mid-gesture. diff --git a/docs/lua-api-parity.md b/docs/lua-api-parity.md index a05cb24..f140806 100644 --- a/docs/lua-api-parity.md +++ b/docs/lua-api-parity.md @@ -12,7 +12,7 @@ Compared against crosspoint-reader at `src/util/lua/LuaBindings*.cpp`. `fs.listDirs`, `fs.listFiles`, `fs.exists`, `fs.readFile`, `fs.writeFile`, `gui.width`, `gui.height`, `gui.fillRect`, `gui.drawRect`, `gui.drawLine`, `sys.millis`, `sys.delay`, `sys.exit`, `log.debug/info/error`, the whole `http` table, -`app.setTickInterval`, `wifi.status`, `wifi.isConnected`, `wifi.localIP`, and the +`sys.setTickInterval`, `wifi.status`, `wifi.isConnected`, `wifi.localIP`, and the `init()` / `draw()` / `on_tick()` callbacks. `init()` is required in both, so a misspelled entry point is an error rather than an app that quietly draws nothing. @@ -27,7 +27,7 @@ strength a status screen wants. Its `state` vocabulary is a subset: crosspoint r | Area | crosspoint-reader | slate32 | Why | |---|---|---|---| | Input | `input.wasPressed(button)` and friends, 8 named buttons | `input.getTouch`, `getRawTouch`, `touched` | Different hardware. A touch panel has no button names and a button device has no coordinates. | -| Drawing | `gui.drawText(font, x, y, text, color, style)`, `getTextWidth(font, text)` | `gui.drawText(text, x, y, color, bg)`, `textWidth(text)` | crosspoint ships several fonts; this firmware has one built-in font, and needs an opaque background colour because the panel is not e-ink. | +| Drawing | `gui.drawText(font, x, y, text, color, style)`, `getTextWidth(font, text)` | `gui.drawText(text, x, y, color, bg)`, `textWidth(text)` | crosspoint ships several fonts; this firmware has one built-in font scaled by `gui.setTextSize(n)`, and needs an opaque background colour because the panel is not e-ink. | | Refresh | `gui.refresh(mode)`, `REFRESH_FULL/HALF/FAST` | none | An LCD has no waveform modes. | | Colour | `COLOR_*` constants, 4 grey levels | `gui.color(r, g, b)` returning RGB565 | 16-bit colour has too many values to enumerate. | | Shapes | `drawRoundedRect` + `fillRoundedRect` | one `gui.roundRect(...)` with gradient and border | Fill and border derive from a single distance field, so their edges cannot disagree. | diff --git a/sdcard/apps/hello/main.lua b/sdcard/apps/hello/main.lua index 470544b..7feba52 100644 --- a/sdcard/apps/hello/main.lua +++ b/sdcard/apps/hello/main.lua @@ -5,7 +5,7 @@ local seconds = 0 local theme = ui.theme function init() - app.setTickInterval(1000) + sys.setTickInterval(1000) gui.clear(theme.bg) gui.drawText("hello from sd card", 10, 10, theme.fg, theme.bg) gui.drawText("touch the screen", 10, 30, theme.muted, theme.bg) diff --git a/sdcard/apps/launcher/main.lua b/sdcard/apps/launcher/main.lua index db99a84..9559b68 100644 --- a/sdcard/apps/launcher/main.lua +++ b/sdcard/apps/launcher/main.lua @@ -1,48 +1,51 @@ local ui = require("ui") +local PAD, GAP = 12, 8 +local CARD_SIZE = 2 -- text scale inside a card; the name is the whole card + local screen -local clock --- Until the clock means something the placeholder holds its width; WiFi and SNTP run --- in the background. -local PLACEHOLDER = "--:--:--" - --- Rows are interactive containers rather than plain buttons, so each can carry a --- name plus secondary text (and an icon later) while still being one tap target. -local function appRow(name) +local function card(name, side) return ui.button{ - pad = {t = 8, r = 10, b = 8, l = 10}, - align = "start", + w = side, h = side, + justify = "center", on_press = function() sys.launch("/apps/" .. name .. "/main.lua") end, - ui.text(name), + ui.label(name, {size = CARD_SIZE, fit = side - 16}), } end function init() - app.setTickInterval(500) -- twice a second, so the seconds digit never visibly lags - -- Fixed width: drawText paints an opaque background only under its own glyphs, so a - -- shorter label would leave the tail of the previous one on screen. - clock = ui.text(PLACEHOLDER, {w = gui.textWidth(PLACEHOLDER), color = ui.theme.muted}) - local header = ui.box{row = true, gap = 12, justify = "between", ui.text("slate32"), clock} - local items = {pad = 12, gap = 8, header} - - local names = fs.listDirs("/apps") + local names = {} + for _, name in ipairs(fs.listDirs("/apps")) do + if name ~= "launcher" then names[#names + 1] = name end + end table.sort(names) + + local side, cols = ui.cardSide(math.max(#names, 1), PAD, GAP) + + -- Rows are filled before ui.box() sees them: the constructor moves a spec's array part + -- into its children, so anything appended afterwards is never laid out. + local rows = {} for _, name in ipairs(names) do - if name ~= "launcher" then items[#items + 1] = appRow(name) end + local row = rows[#rows] + if not row or #row >= cols then + row = {} + rows[#rows + 1] = row + end + row[#row + 1] = card(name, side) end - if #names <= 1 then - items[#items + 1] = ui.text("no apps in /apps", {color = ui.theme.muted}) + + local items = {pad = PAD, gap = GAP} + for _, row in ipairs(rows) do + row.row, row.gap = true, 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") end -function on_tick() - clock:setText(sys.clockSynced() and os.date("%H:%M:%S") or PLACEHOLDER) -end - function draw() screen:draw() end function on_touch_down(x, y) screen:down(x, y) end function on_touch_up(x, y) screen:up(x, y) end diff --git a/sdcard/apps/settings/main.lua b/sdcard/apps/settings/main.lua index 980fcdd..6434748 100644 --- a/sdcard/apps/settings/main.lua +++ b/sdcard/apps/settings/main.lua @@ -1,6 +1,7 @@ local ui = require("ui") local INSET = 30 +local MENU_PAD, MENU_GAP = 12, 8 local screen, message, passwordLabel, passwordRow local mode = "menu" @@ -53,6 +54,7 @@ local function finishCalibration() message = ok and "calibration saved" or "save failed" mode = "menu" gui.setRotation(sys.getRotation() / 90) + gui.fullscreen(false) buildMenu() end @@ -61,6 +63,9 @@ function startCalibration() mode = "calibrate" samples, pending, armed = {}, nil, false gui.setRotation(0) + -- The targets sit at the physical corners and the samples are read in panel + -- coordinates, so the status bar cannot be allowed to shift the frame. + gui.fullscreen(true) drawTarget(1) end @@ -94,40 +99,54 @@ local function zoneLabel() return current end -local function cycleTimezone(button) +local function cycleTimezone() local current = sys.getTimezone() local next_index = 1 for index, zone in ipairs(zones) do if zone.tz == current then next_index = index % #zones + 1 end end local ok = sys.setTimezone(zones[next_index].tz) - if ok and not message then - button:setText("timezone: " .. zoneLabel()) - return - end message = ok and "timezone saved" or "save failed" buildMenu() end -local function statusLabel(status) - if status.state == "connected" then return "wifi: " .. status.ssid end - if status.ssid ~= "" then return "wifi: " .. status.state end - return "wifi: not configured" +local function wifiValue(status) + if status.state == "connected" then return status.ssid end + if status.ssid ~= "" then return status.state end + return "not set" +end + +local function card(side, title, value, on_press) + local spec = { + w = side, h = side, gap = 6, + justify = "center", + on_press = on_press, + ui.label(title, {size = 2, fit = side - 12}), + } + if value then spec[#spec + 1] = ui.label(value, {fit = side - 12}) end + return ui.button(spec) end function buildMenu() mode = "menu" - local items = { - pad = 12, - gap = 8, - ui.text("settings"), - ui.button{label = "calibrate touch", on_press = startCalibration}, - ui.button{label = "rotation: " .. sys.getRotation() .. " deg", on_press = cycleRotation}, - ui.button{label = statusLabel(wifi.status()), on_press = buildWifi}, - ui.button{label = "theme: " .. ui.themeName, on_press = cycleTheme}, - ui.button{label = "timezone: " .. zoneLabel(), on_press = cycleTimezone}, - ui.button{label = "exit", on_press = sys.exit}, + -- No title: the status bar already names the running app. The status line under the + -- grid is worth its rows, so the cards give it room rather than pushing it off screen. + local side, cols = ui.cardSide(6, MENU_PAD, MENU_GAP, message and 20 or 0) + local cards = { + card(side, "calibrate", "touch", startCalibration), + card(side, "rotation", sys.getRotation() .. " deg", cycleRotation), + card(side, "wifi", wifiValue(wifi.status()), buildWifi), + card(side, "theme", ui.themeName, cycleTheme), + card(side, "timezone", zoneLabel(), cycleTimezone), + card(side, "exit", nil, sys.exit), } + + local items = {pad = MENU_PAD, gap = MENU_GAP} + 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 + items[#items + 1] = ui.box(row) + end if message then items[#items + 1] = ui.text(message) end themed(items) end @@ -162,7 +181,7 @@ end function buildWifi() mode = "wifi" local status = wifi.status() - local items = {pad = 12, gap = 8, ui.text("wifi"), ui.text(statusLabel(status))} + local items = {pad = 12, gap = 8, ui.text("wifi"), ui.text("wifi: " .. wifiValue(status))} if status.state == "connected" then items[#items + 1] = ui.text("ip: " .. status.ip) end @@ -332,7 +351,7 @@ function buildKeyboard() end function init() - app.setTickInterval(50) -- calibration samples the raw panel between draws + sys.setTickInterval(50) -- calibration samples the raw panel between draws buildMenu() log.info("settings ready") end diff --git a/sdcard/lib/statusbar.lua b/sdcard/lib/statusbar.lua new file mode 100644 index 0000000..3e7d721 --- /dev/null +++ b/sdcard/lib/statusbar.lua @@ -0,0 +1,46 @@ +-- The top bar, painted by the firmware once a second in every app. It draws in panel +-- coordinates (the firmware drops the app viewport around the call), so gui.width() +-- here is the whole screen. + +local ui = require("ui") + +local PAD = 6 + +-- The firmware reads both: `height` is the strip it keeps apps out of, `interval` is how +-- often it calls draw(). +local M = {height = 22, interval = 1000} +local BAR_H = M.height + +local function clock() + return sys.clockSynced() and os.date("%H:%M") or "--:--" +end + +-- Bars rather than glyphs: there is no icon font, and signal strength is a scale. +local function drawSignal(x, y, color, muted) + local status = wifi.status() + local bars = 0 + if status.state == "connected" then + bars = status.rssi >= -60 and 3 or status.rssi >= -75 and 2 or 1 + end + for i = 1, 3 do + local h = i * 3 + gui.fillRect(x + (i - 1) * 4, y + 9 - h, 3, h, i <= bars and color or muted) + end +end + +function M.draw() + local theme = ui.theme + gui.setTextSize(1) -- panel state: the app may have left it scaled up + local w = gui.width() + gui.fillRect(0, 0, w, BAR_H, theme.bg) + 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 time = clock() + gui.drawText(time, w - PAD - gui.textWidth(time), textY, theme.muted, theme.bg) + drawSignal(w - PAD - gui.textWidth(time) - 8 - 11, textY, theme.fg, theme.muted) +end + +return M diff --git a/sdcard/lib/ui.lua b/sdcard/lib/ui.lua index 9abbf8f..0321dd5 100644 --- a/sdcard/lib/ui.lua +++ b/sdcard/lib/ui.lua @@ -315,13 +315,17 @@ function ui.spacer(spec) return component{w = spec.w, h = spec.h} end +-- Text size is panel state, not node state, so every measure and paint sets it: a +-- neighbour at another size would otherwise decide this node's metrics. local function measureText(self, available) + gui.setTextSize(self.size or 1) self.mw = resolve(self.w, available.w, "width") or gui.textWidth(self.label) self.mh = resolve(self.h, available.h, "height") or gui.fontHeight() return self.mw, self.mh end local function paintText(self) + gui.setTextSize(self.size or 1) gui.drawText(self.label, self.rect.x, self.rect.y, self.color, self.bg) end @@ -341,6 +345,32 @@ function ui.text(label, spec) return node end +-- Geometry for a grid of square cards: a wide frame takes another column, and the side +-- shrinks until every row fits, because there is no scrolling and a card below the fold +-- cannot be tapped. `reserve` is height the caller needs for anything under the grid. +-- ponytail: enough cards make them unusably small; that is the point to add scrolling. +function ui.cardSide(count, pad, gap, reserve) + local cols = gui.width() >= gui.height() and 3 or 2 + local rows = math.ceil(count / cols) + local byWidth = (gui.width() - 2 * pad - (cols - 1) * gap) // cols + local byHeight = (gui.height() - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows + return math.min(byWidth, byHeight), cols +end + +-- A text node sized to its own glyphs, so a centering parent has something to center: a +-- plain ui.text takes the parent's full width and paints from its left edge. `fit` clips +-- the label to a pixel budget, because the panel has one font and no wrapping. +function ui.label(text, spec) + spec = spec or {} + gui.setTextSize(spec.size or 1) + if spec.fit and gui.textWidth(text) > spec.fit then + while #text > 1 and gui.textWidth(text .. "~") > spec.fit do text = text:sub(1, -2) end + text = text .. "~" + end + spec.w, spec.fit = gui.textWidth(text), nil + return ui.text(text, spec) +end + local function paintButton(self) local r = self.rect local gradient = self.pressed and self.press_gradient or (not self.pressed and self.gradient) diff --git a/src/gfx/statusbar.h b/src/gfx/statusbar.h new file mode 100644 index 0000000..f8a2377 --- /dev/null +++ b/src/gfx/statusbar.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +// Host-owned chrome across the top of the panel. Apps draw inside a viewport below it, +// so nothing an app paints can reach the bar and every app's gui.height() shrinks by the +// bar without the app knowing it exists. Both the painting and the height live in +// /lib/statusbar.lua; the firmware only owns the clipping. +namespace statusbar { + +constexpr int16_t DEFAULT_H = 22; // when /lib/statusbar.lua declares no height + +// resetViewport() first: width()/height() report the *viewport* once one is set, so +// re-applying over an existing viewport would shrink the app area again every time. +// setRotation() leaves the old viewport metrics behind, so every rotation needs this too. +inline void apply(TFT_eSPI& tft, int16_t barHeight) { + tft.resetViewport(); + if (barHeight > 0) tft.setViewport(0, barHeight, tft.width(), tft.height() - barHeight, true); +} + +} // namespace statusbar diff --git a/src/lua/bindings/gui.cpp b/src/lua/bindings/gui.cpp index ff5ac76..a056eac 100644 --- a/src/lua/bindings/gui.cpp +++ b/src/lua/bindings/gui.cpp @@ -96,6 +96,13 @@ static int l_gui_fontHeight(lua_State* L) { return 1; } +// Integer multiples of the one built-in font, which is all the driver offers. fontHeight() +// and textWidth() follow it, so layout keeps working at any size. +static int l_gui_setTextSize(lua_State* L) { + app(L)->tft.setTextSize(constrain(luaL_checkinteger(L, 1), 1, 8)); + return 0; +} + static int l_gui_textWidth(lua_State* L) { lua_pushinteger(L, app(L)->tft.textWidth(luaL_checkstring(L, 1))); return 1; @@ -113,6 +120,14 @@ static int l_gui_drawText(lua_State* L) { // Only the current frame; sys.setRotation() is the persisted one. static int l_gui_setRotation(lua_State* L) { app(L)->tft.setRotation(luaL_checkinteger(L, 1)); + app(L)->applyViewport(); + return 0; +} + +// Escape hatch for an app that must reach the physical edges, like touch calibration: +// the status bar and the viewport that keeps apps out of it both go away. +static int l_gui_fullscreen(lua_State* L) { + app(L)->setFullscreen(lua_toboolean(L, 1)); return 0; } @@ -128,6 +143,9 @@ void registerGui(lua_State* L) { // --- Panel width in pixels, for the current rotation. // @return integer {"width", l_gui_width}, + // --- Hands the app the whole panel, hiding the status bar, until it is turned off. + // @param on boolean + {"fullscreen", l_gui_fullscreen}, // --- Panel height in pixels, for the current rotation. // @return integer {"height", l_gui_height}, @@ -181,7 +199,10 @@ void registerGui(lua_State* L) { // @param bottom integer|nil Bottom of the gradient, defaults to top. // @param border integer|nil Border color; omitted draws no border. {"roundRect", l_gui_roundRect}, - // --- Height of the current font in pixels. + // --- Scales the built-in font by a whole number, for this app only. + // @param size integer 1 to 8. + {"setTextSize", l_gui_setTextSize}, + // --- Height of the current font in pixels, at the current text size. // @return integer {"fontHeight", l_gui_fontHeight}, // --- Width the given text would occupy in pixels. diff --git a/src/lua/bindings/input.cpp b/src/lua/bindings/input.cpp index 279eccc..db8757e 100644 --- a/src/lua/bindings/input.cpp +++ b/src/lua/bindings/input.cpp @@ -12,7 +12,7 @@ static int l_input_getTouch(lua_State* L) { } TS_Point point = owner->touch.getPoint(); int16_t x, y; - LuaApp::mapTouch(owner->tft, point, x, y); + owner->mapTouch(point, x, y); lua_pushinteger(L, x); lua_pushinteger(L, y); return 2; diff --git a/src/lua/bindings/sys.cpp b/src/lua/bindings/sys.cpp index 99812ad..1ed8de2 100644 --- a/src/lua/bindings/sys.cpp +++ b/src/lua/bindings/sys.cpp @@ -15,6 +15,11 @@ static int l_sys_exit(lua_State* L) { return 0; } +static int l_sys_appName(lua_State* L) { + lua_pushstring(L, app(L)->name().c_str()); + return 1; +} + static int l_sys_launch(lua_State* L) { app(L)->requestLaunch(luaL_checkstring(L, 1)); app(L)->requestExit(); @@ -33,6 +38,7 @@ static int l_sys_setRotation(lua_State* L) { return 1; } app(L)->tft.setRotation(settings.rotationIndex()); + app(L)->applyViewport(); lua_pushboolean(L, settings.save()); return 1; } @@ -98,7 +104,7 @@ static int logAt(lua_State* L, const char* level) { // Rejected rather than ignored when on_tick is absent: the chunk body has already run // by the time init() calls this, so a missing callback is a typo, not a race. -static int l_app_setTickInterval(lua_State* L) { +static int l_sys_setTickInterval(lua_State* L) { lua_Integer requested = luaL_checkinteger(L, 1); if (requested <= 0) { app(L)->setTickInterval(0); @@ -107,7 +113,7 @@ static int l_app_setTickInterval(lua_State* L) { lua_getglobal(L, "on_tick"); bool hasTick = lua_isfunction(L, -1); lua_pop(L, 1); - if (!hasTick) return luaL_error(L, "app.setTickInterval() requires on_tick()"); + if (!hasTick) return luaL_error(L, "sys.setTickInterval() requires on_tick()"); uint32_t interval = requested < (lua_Integer)LuaApp::MIN_TICK_MS ? LuaApp::MIN_TICK_MS : requested > (lua_Integer)LuaApp::MAX_TICK_MS ? LuaApp::MAX_TICK_MS @@ -137,6 +143,12 @@ void registerSys(lua_State* L) { {"delay", l_sys_delay}, // --- Ends this app and returns to the launcher. {"exit", l_sys_exit}, + // --- Directory name of the running app, for example "settings". + // @return string + {"appName", l_sys_appName}, + // --- Sets how often on_tick() runs. Errors when on_tick is not defined. + // @param intervalMs integer 0 stops ticking; anything else is clamped to 33..3600000. + {"setTickInterval", l_sys_setTickInterval}, // --- Ends this app and starts another one. // @param path string Absolute path to the app's main.lua. {"launch", l_sys_launch}, @@ -175,15 +187,7 @@ void registerSys(lua_State* L) { luaL_newlib(L, lib); lua_setglobal(L, "sys"); - // These two are too small to deserve their own translation units. - static const luaL_Reg appLib[] = { - // --- Sets how often on_tick() runs. Errors when on_tick is not defined. - // @param intervalMs integer 0 stops ticking; anything else is clamped to 33..3600000. - {"setTickInterval", l_app_setTickInterval}, - {nullptr, nullptr}}; - luaL_newlib(L, appLib); - lua_setglobal(L, "app"); - + // Too small to deserve its own translation unit. static const luaL_Reg logLib[] = { // --- Writes a debug line to the serial log. // @param message string diff --git a/src/lua/lua_app.cpp b/src/lua/lua_app.cpp index baa6359..f2bdb03 100644 --- a/src/lua/lua_app.cpp +++ b/src/lua/lua_app.cpp @@ -5,6 +5,7 @@ #include +#include "../gfx/statusbar.h" #include "../settings.h" #include "bindings.h" @@ -19,7 +20,7 @@ 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(TFT_eSPI& tft, const TS_Point& p, int16_t& x, int16_t& y) { +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) { @@ -40,6 +41,17 @@ void LuaApp::mapTouch(TFT_eSPI& tft, const TS_Point& p, int16_t& x, int16_t& y) 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(); +} + +void LuaApp::applyViewport() { statusbar::apply(tft, barInset()); } + +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) {} @@ -105,7 +117,14 @@ bool LuaApp::load(const char* path) { String appDir = path; int slash = appDir.lastIndexOf('/'); - installLoader(slash > 0 ? appDir.substring(0, slash).c_str() : "/"); + 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(); if (loadScript(state, path) != LUA_OK) { fail(lua_tostring(state, -1)); @@ -123,6 +142,50 @@ bool LuaApp::load(const char* path) { 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 = statusbar::DEFAULT_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)); + barBroken = true; + lua_pop(state, 2); + return; + } + barHeight = barField(state, "height", statusbar::DEFAULT_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); + tft.resetViewport(); // the bar paints in panel coordinates, the app does not + if (lua_pcall(state, 0, 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; @@ -165,7 +228,8 @@ void LuaApp::loop() { bool touched = touch.touched(); if (touched) { TS_Point p = touch.getPoint(); - mapTouch(tft, p, lastX, lastY); + mapTouch(p, lastX, lastY); + if (lastY < 0) touched = false; // the bar is the host's, and it takes no input yet } if (touched && !lastTouched) { fireTouch("on_touch_down", lastX, lastY); @@ -191,5 +255,11 @@ void LuaApp::loop() { nextDrawMs = now + DRAW_INTERVAL_MS; lua_getglobal(state, "draw"); callGlobal("draw"); + if (!running()) return; + } + + if (barInset() > 0 && now >= nextBarMs) { + nextBarMs = now + barIntervalMs; + drawStatusBar(); } } diff --git a/src/lua/lua_app.h b/src/lua/lua_app.h index ce2d64f..b6a990f 100644 --- a/src/lua/lua_app.h +++ b/src/lua/lua_app.h @@ -18,6 +18,17 @@ class LuaApp { bool load(const char* path); void loop(); + // Directory name of the running app ("launcher"), 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 + // needs the physical edges; almost nothing else should. + void setFullscreen(bool on); + + // Re-clips the app out of the status bar. Needed after every setRotation(), which + // leaves the previous viewport metrics behind. + void applyViewport(); + // Set by sys.launch(); the host loop reads it once the app has torn down. void requestLaunch(const char* path); String takePendingLaunch(); @@ -28,7 +39,7 @@ class LuaApp { // can leave the message on screen long enough to read. bool takeFailure(); - // Set from Lua by app.setTickInterval(); 0 stops the ticks. + // Set from Lua by sys.setTickInterval(); 0 stops the ticks. void setTickInterval(uint32_t intervalMs); static constexpr uint32_t MIN_TICK_MS = 33; // no point ticking faster than a draw @@ -39,7 +50,8 @@ class LuaApp { static constexpr int16_t PANEL_W = 320; static constexpr int16_t PANEL_H = 480; - static void mapTouch(TFT_eSPI& tft, const TS_Point& p, int16_t& x, int16_t& y); + // App coordinates, so the status bar's rows are subtracted unless the app owns the panel. + void mapTouch(const TS_Point& p, int16_t& x, int16_t& y) const; static constexpr size_t READ_CAP = 64 * 1024; static constexpr int MAX_SPAN = 480; // longest panel edge, so one row buffer covers any shape @@ -51,6 +63,14 @@ class LuaApp { String pendingLaunch; bool lastTouched = false; bool ignoreRelease = false; + bool fullscreen = 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. + bool barBroken = false; + int16_t barHeight = 0; + uint32_t nextBarMs = 0; + uint32_t barIntervalMs = 0; // The controller reports no position once the finger lifts, so on_touch_up and the // tap alias replay the last point seen while it was down. int16_t lastX = 0, lastY = 0; @@ -58,11 +78,19 @@ class LuaApp { void closeState(); void installLoader(const char* appDir); void fireTouch(const char* name, int16_t x, int16_t y); + void loadStatusBar(); + void drawStatusBar(); uint32_t tickIntervalMs = 0; uint32_t nextTickMs = 0; uint32_t nextDrawMs = 0; static constexpr uint32_t DRAW_INTERVAL_MS = 33; + // Default only: /lib/statusbar.lua overrides it with an `interval` field. + static constexpr uint32_t BAR_INTERVAL_MS = 1000; + + // Zero whenever the app owns the panel, so one accessor answers both the viewport + // and the touch offset. + int16_t barInset() const { return (fullscreen || barBroken) ? 0 : barHeight; } void registerBindings(); bool callGlobal(const char* name, int nargs = 0); diff --git a/src/main.cpp b/src/main.cpp index f8329e4..7f634c7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,6 +4,7 @@ #include #include +#include "gfx/statusbar.h" #include "lua/lua_app.h" #include "net.h" #include "settings.h" @@ -35,6 +36,7 @@ String nextApp; // Last resort only: the UI lives in Lua, so this exists purely to explain why // nothing else could run. void fallbackScreen(const char* message) { + tft.resetViewport(); // no app, no status bar: this message owns the panel tft.setRotation(0); tft.fillScreen(TFT_WHITE); tft.setTextColor(TFT_RED, TFT_WHITE); @@ -46,7 +48,9 @@ void fallbackScreen(const char* message) { } void startApp(const String& path) { + // Full panel until the app's own status bar module reports its height in load(). 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"); diff --git a/stubs/slate32.lua b/stubs/slate32.lua index c96ef5f..0a27af0 100644 --- a/stubs/slate32.lua +++ b/stubs/slate32.lua @@ -4,13 +4,6 @@ -- Point your editor's Lua language server at this file to get completion for the -- firmware API inside sdcard/apps and sdcard/lib. ----@class applib -app = {} - ---- Sets how often on_tick() runs. Errors when on_tick is not defined. ----@param intervalMs integer 0 stops ticking; anything else is clamped to 33..3600000. -function app.setTickInterval(intervalMs) end - ---@class fslib fs = {} @@ -47,6 +40,10 @@ gui = {} ---@return integer function gui.width() end +--- Hands the app the whole panel, hiding the status bar, until it is turned off. +---@param on boolean +function gui.fullscreen(on) end + --- Panel height in pixels, for the current rotation. ---@return integer function gui.height() end @@ -108,7 +105,11 @@ function gui.drawText(text, x, y, color, bg) end ---@param border integer? Border color; omitted draws no border. function gui.roundRect(x, y, w, h, radius, bg, top, bottom, border) end ---- Height of the current font in pixels. +--- Scales the built-in font by a whole number, for this app only. +---@param size integer 1 to 8. +function gui.setTextSize(size) end + +--- Height of the current font in pixels, at the current text size. ---@return integer function gui.fontHeight() end @@ -227,6 +228,14 @@ function sys.delay(ms) end --- Ends this app and returns to the launcher. function sys.exit() end +--- Directory name of the running app, for example "settings". +---@return string +function sys.appName() end + +--- Sets how often on_tick() runs. Errors when on_tick is not defined. +---@param intervalMs integer 0 stops ticking; anything else is clamped to 33..3600000. +function sys.setTickInterval(intervalMs) end + --- Ends this app and starts another one. ---@param path string Absolute path to the app's main.lua. function sys.launch(path) end diff --git a/test/fake_device.lua b/test/fake_device.lua index aae05bf..1ebec47 100644 --- a/test/fake_device.lua +++ b/test/fake_device.lua @@ -14,6 +14,9 @@ local device = { theme = "light", clockSynced = false, tickInterval = 0, + appName = "test", + textSize = 1, + fullscreen = false, timezone = "UTC0", raw = nil, -- pending raw touch reading, {x, y} or nil networks = {}, -- what wifi.scan() returns @@ -47,14 +50,21 @@ function device.install() drawText = function(label, x, y) device.painted[#device.painted + 1] = {label = label, x = x, y = y} end, - fontHeight = function() return device.fontHeight end, - textWidth = function(text) return #text * device.charWidth end, + setTextSize = function(size) device.textSize = size end, + fontHeight = function() return device.fontHeight * device.textSize end, + textWidth = function(text) return #text * device.charWidth * device.textSize end, setRotation = function() end, + fullscreen = function(on) device.fullscreen = on and true or false end, } sys = { millis = function() return device.now end, exit = function() device.exited = true end, + appName = function() return device.appName end, + setTickInterval = function(ms) + assert(ms == 0 or on_tick, "setTickInterval without on_tick") + device.tickInterval = ms + end, launch = function(path) device.launched = path end, getRotation = function() return device.rotation end, setRotation = function(degrees) @@ -100,13 +110,6 @@ function device.install() forget = function() device.connected = nil return saved() end, } - app = { - setTickInterval = function(ms) - assert(ms == 0 or on_tick, "setTickInterval without on_tick") - device.tickInterval = ms - end, - } - log = {debug = function() end, info = function() end, error = function() end} return device diff --git a/test/settings_calibration.lua b/test/settings_calibration.lua index dcf213e..f0fa2a7 100644 --- a/test/settings_calibration.lua +++ b/test/settings_calibration.lua @@ -20,7 +20,9 @@ local function tapRow(prefix) local target for _, item in ipairs(device.painted) do targets[item.label] = item - if item.label:sub(1, #prefix) == prefix then target = item end + -- First match wins: the status line at the bottom repeats a row's words back + -- ("timezone saved"), and tapping that hits nothing. + if not target and item.label:sub(1, #prefix) == prefix then target = item end end if not target then for label, item in pairs(targets) do @@ -50,20 +52,18 @@ local s4 = {x = 3800 - raw(w - inset, w) + 200, y = s2.y} local fx0, _, fx1 = computeCalibration(s3, s4, w, h, inset) assert(fx0 > fx1, "flipped axis should descend") --- Timezone cycles through the picker list and redraws only its button. +-- Timezone cycles through the picker list. local zones = require("timezones") init() -tapRow("timezone:") +tapRow("timezone") assert(sys.getTimezone() == zones[2].tz, "timezone " .. sys.getTimezone()) -tapRow("timezone:") -assert(#device.painted == 1 and device.painted[1].label == "timezone: " .. zones[2].name, - "timezone change repainted the settings list") +tapRow("timezone") assert(sys.getTimezone() == zones[3].tz, "timezone " .. sys.getTimezone()) -- Rotation cycles through the four quarter turns and wraps back to 0. init() for _, expected in ipairs({90, 180, 270, 0}) do - tapRow("rotation:") + tapRow("rotation") assert(sys.getRotation() == expected, "rotation " .. sys.getRotation()) end @@ -86,7 +86,7 @@ assert(math.abs(saved[1] - 200) <= 1, "saved x0 " .. saved[1]) -- Open networks connect directly from scan results. init() device.networks = {{ssid = "qemu", rssi = -25, secure = false}} -tapRow("wifi:") +tapRow("wifi") tapRow("scan networks") on_tick() tapRow("qemu") @@ -96,7 +96,7 @@ assert(device.connected[1] == "qemu" and device.connected[2] == "", "open wifi c -- Secure networks route through the keyboard and preserve typed punctuation. init() device.networks = {{ssid = "secure", rssi = -40, secure = true}} -tapRow("wifi:") +tapRow("wifi") tapRow("scan networks") on_tick() tapRow("secure")