From b5146b8642a4a88eb113911a2de1606306565dc6 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sun, 2 Aug 2026 10:49:01 -0400 Subject: [PATCH] refactor(lua): give the status bar its own dirty tracking and split settings out of sys The bar repainted itself whole every second. It now compares each field against what it last painted, adds seconds and a memory percentage, and keys the cache on gui.getRotation() and ui.themeName so rotation and theme changes still repaint it. Invalidation lives entirely in Lua; the firmware's push flag and gfx/statusbar.h are gone. Bindings follow getName/setName/isName, persisted preferences move from sys to a settings table, and gui.setRotation takes degrees like settings does. A bar that dies mid-run now keeps its rows reserved rather than silently resizing the running app. --- Makefile | 2 +- README.md | 20 +++--- docs/lua-api-parity.md | 16 ++--- sdcard/apps/hello/main.lua | 2 +- sdcard/apps/settings/main.lua | 36 +++++------ sdcard/lib/statusbar.lua | 92 ++++++++++++++++++++++------ sdcard/lib/ui.lua | 36 ++++++----- src/gfx/statusbar.h | 21 ------- src/lua/bindings.h | 1 + src/lua/bindings/gui.cpp | 31 +++++++--- src/lua/bindings/input.cpp | 2 +- src/lua/bindings/settings.cpp | 111 +++++++++++++++++++++++++++++++++ src/lua/bindings/sys.cpp | 112 +++++----------------------------- src/lua/bindings/wifi.cpp | 4 +- src/lua/lua_app.cpp | 23 +++++-- src/lua/lua_app.h | 8 ++- src/main.cpp | 3 +- stubs/slate32.lua | 108 +++++++++++++++++--------------- test/fake_device.lua | 31 ++++++---- test/settings_calibration.lua | 6 +- test/statusbar_dirty.lua | 59 ++++++++++++++++++ 21 files changed, 454 insertions(+), 270 deletions(-) delete mode 100644 src/gfx/statusbar.h create mode 100644 src/lua/bindings/settings.cpp create mode 100644 test/statusbar_dirty.lua diff --git a/Makefile b/Makefile index 95eccfe..d5ce0ce 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ LUA ?= lua CXX ?= c++ CXXFLAGS ?= -std=c++11 -Wall -Wextra BUILD_DIR := .pio/build/esp32-32e -LUA_TESTS := test/ui_layout.lua test/ui_theme.lua test/settings_calibration.lua +LUA_TESTS := test/ui_layout.lua test/ui_theme.lua test/settings_calibration.lua test/statusbar_dirty.lua .PHONY: test test-lua test-cpp test-stubs stubs build upload monitor clean diff --git a/README.md b/README.md index 4fca358..f1d5a9e 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,18 @@ button in the status bar does too. | Module | Functions | |---|---| -| `gui` | `width()`, `height()`, `clear(color)`, `fillRect(x,y,w,h,c)`, `drawRect(x,y,w,h,c)`, `fillCircle(x,y,r,c,bg)`, `drawLine(x1,y1,x2,y2,c)`, `drawText(text,x,y,fg,bg)`, `roundRect(x,y,w,h,radius,bg,top,bottom,border)`, `fontHeight()`, `textWidth(text)`, `setRotation(0-3)`, `color(r,g,b)` | -| `input` | `getTouch()` -> `x,y` or nil, `getRawTouch()` -> raw ADC `x,y` or nil, `touched()` | +| `gui` | `getWidth()`, `getHeight()`, `clear(color)`, `fillRect(x,y,w,h,c)`, `drawRect(x,y,w,h,c)`, `fillCircle(x,y,r,c,bg)`, `drawLine(x1,y1,x2,y2,c)`, `drawText(text,x,y,fg,bg)`, `roundRect(x,y,w,h,radius,bg,top,bottom,border)`, `getFontHeight()`, `getTextWidth(text)`, `getRotation()`, `setRotation(deg)`, `setFullscreen(on)`, `color(r,g,b)` | +| `input` | `getTouch()` -> `x,y` or nil, `getRawTouch()` -> raw ADC `x,y` or nil, `isTouched()` | | `fs` | `readFile(path)`, `writeFile(path, data)`, `exists(path)`, `listFiles(path)`, `listDirs(path)` | -| `sys` | `millis()`, `exit()`, `launch(path)`, `setCalibration(x0,y0,x1,y1)`, `getRotation()`, `setRotation(deg)`, `getTheme()`, `setTheme(name)` | -| `wifi` | `scan()` -> `{ssid,rssi,secure}[]`, `connect(ssid,password)`, `status()` -> `{state,ssid,ip,rssi}`, `forget()` | -| `log` | `info(msg)` (serial) | +| `sys` | `getMillis()`, `delay(ms)`, `exit()`, `launch(path)`, `getAppName()`, `getMemory()` -> `free,total`, `isClockSynced()`, `setTickInterval(ms)` | +| `settings` | `getRotation()`, `setRotation(deg)`, `getTheme()`, `setTheme(name)`, `getTimezone()`, `setTimezone(tz)`, `setCalibration(x0,y0,x1,y1)` | +| `wifi` | `scan()` -> `{ssid,rssi,secure}[]`, `connect(ssid,password)`, `getStatus()` -> `{state,ssid,ip,rssi}`, `getLocalIP()`, `isConnected()`, `disconnect()`, `forget()` | +| `log` | `debug(msg)`, `info(msg)`, `error(msg)` (serial) | + +Accessors are `getName` / `setName` / `isName`; bare names are actions (`gui.fillRect`) +or pure conversions (`gui.color`, `http.urlencode`). `settings.*` is persisted user +intent; `gui.getRotation()` is the frame actually being drawn, which differs while an +app rotates the panel transiently. Colors are RGB565 integers; build them with `gui.color(r, g, b)`. @@ -76,7 +82,7 @@ return { ``` Missing file means the built-in defaults are used. `apps/settings` walks two -crosshairs and saves the result via `sys.setCalibration()`, cycles `rotation` +crosshairs and saves the result via `settings.setCalibration()`, cycles `rotation` through 0, 90, 180 and 270 degrees, and scans/selects Wi-Fi networks with an on-screen password keyboard. A saved network reconnects at boot. @@ -85,7 +91,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)` +afterwards, so `settings.setRotation()` is safe at any time. `gui.setRotation(degrees)` 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. diff --git a/docs/lua-api-parity.md b/docs/lua-api-parity.md index 91ae927..ca087c8 100644 --- a/docs/lua-api-parity.md +++ b/docs/lua-api-parity.md @@ -10,13 +10,13 @@ Compared against crosspoint-reader at `src/util/lua/LuaBindings*.cpp`. ## Identical `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, -`sys.setTickInterval`, `wifi.status`, `wifi.isConnected`, `wifi.localIP`, and the +`gui.getWidth`, `gui.getHeight`, `gui.fillRect`, `gui.drawRect`, `gui.drawLine`, +`sys.getMillis`, `sys.delay`, `sys.exit`, `log.debug/info/error`, the whole `http` table, +`sys.setTickInterval`, `wifi.getStatus`, `wifi.isConnected`, `wifi.getLocalIP`, 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. -`wifi.status()` returns `{state, ssid, ip, rssi}` in both. crosspoint returned a bare +`wifi.getStatus()` returns `{state, ssid, ip, rssi}` in both. crosspoint returned a bare string until this was reconciled; a string had nowhere to put the address and signal strength a status screen wants. Its `state` vocabulary is a subset: crosspoint reports `disconnected`, `connecting`, `connected` and `failed`, while this firmware adds @@ -27,13 +27,13 @@ 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 scaled by `gui.setTextSize(n)`, 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)`, `gui.getTextWidth(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. | -| 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. | +| Themes | none | `settings.getTheme/setTheme`, `/lib/theme.lua` | Colour panel. | +| Rotation | `gui.setOrientation("portrait")` | `settings.setRotation(degrees)` persisted, `gui.setRotation(degrees)` for one frame | This device stores rotation in settings and remaps touch to match. | +| Clock | nothing exposed; UTC offset is a C++ setting | `sys.isClockSynced`, `settings.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)`, 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. | diff --git a/sdcard/apps/hello/main.lua b/sdcard/apps/hello/main.lua index 7feba52..a1b0d0c 100644 --- a/sdcard/apps/hello/main.lua +++ b/sdcard/apps/hello/main.lua @@ -9,7 +9,7 @@ function init() 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) - log.info("hello app started, screen " .. gui.width() .. "x" .. gui.height()) + log.info("hello app started, screen " .. gui.getWidth() .. "x" .. gui.getHeight()) end function on_tick() diff --git a/sdcard/apps/settings/main.lua b/sdcard/apps/settings/main.lua index df73ccd..02b83a7 100644 --- a/sdcard/apps/settings/main.lua +++ b/sdcard/apps/settings/main.lua @@ -1,4 +1,7 @@ local ui = require("ui") +-- The same module instance the firmware paints the bar with, so toggling fullscreen +-- through it drops the cache that would otherwise hide the repaint. +local statusbar = require("statusbar") local INSET = 30 local MENU_PAD, MENU_GAP = 12, 8 @@ -50,11 +53,11 @@ local function drawTarget(n) end local function finishCalibration() - local ok = sys.setCalibration(computeCalibration(samples[1], samples[2], 320, 480, INSET)) + local ok = settings.setCalibration(computeCalibration(samples[1], samples[2], 320, 480, INSET)) message = ok and "calibration saved" or "save failed" mode = "menu" - gui.setRotation(sys.getRotation() / 90) - gui.fullscreen(false) + gui.setRotation(settings.getRotation()) + statusbar.setFullscreen(false) buildMenu() end @@ -65,12 +68,12 @@ function startCalibration() 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) + statusbar.setFullscreen(true) drawTarget(1) end function cycleRotation() - local ok = sys.setRotation((sys.getRotation() + 90) % 360) + local ok = settings.setRotation((settings.getRotation() + 90) % 360) message = ok and "rotation saved" or "save failed" buildMenu() end @@ -81,8 +84,7 @@ local function cycleTheme() for index, name in ipairs(names) do if name == ui.themeName then next_index = index % #names + 1 end end - local ok = sys.setTheme(names[next_index]) - ui.reloadTheme() + local ok = ui.setTheme(names[next_index]) message = ok and "theme saved" or "save failed" buildMenu() end @@ -92,7 +94,7 @@ local zones = require("timezones") -- The stored value is a POSIX rule, so a zone set by hand and missing from the list -- shows its rule rather than pretending to be the first entry. local function zoneLabel() - local current = sys.getTimezone() + local current = settings.getTimezone() for _, zone in ipairs(zones) do if zone.tz == current then return zone.name end end @@ -100,12 +102,12 @@ local function zoneLabel() end local function cycleTimezone() - local current = sys.getTimezone() + local current = settings.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) + local ok = settings.setTimezone(zones[next_index].tz) message = ok and "timezone saved" or "save failed" buildMenu() end @@ -134,8 +136,8 @@ function buildMenu() 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, "rotation", settings.getRotation() .. " deg", cycleRotation), + card(side, "wifi", wifiValue(wifi.getStatus()), buildWifi), card(side, "theme", ui.themeName, cycleTheme), card(side, "timezone", zoneLabel(), cycleTimezone), card(side, "exit", nil, sys.exit), @@ -169,7 +171,7 @@ end local function confirmForget() dialog = ui.confirm{ title = "forget network?", - message = wifi.status().ssid, + message = wifi.getStatus().ssid, ok = "forget", on_ok = forgetNetwork, on_cancel = function() @@ -182,7 +184,7 @@ end function buildWifi() mode = "wifi" - local status = wifi.status() + local status = wifi.getStatus() 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) @@ -272,8 +274,8 @@ local function drawKey(node, label, x, y, w) local gradient = node.gradient local top, bottom = gradient[1], gradient[2] gui.roundRect(x, y, w, KEY_H, node.radius, node.bg, top, bottom, node.color) - gui.drawText(label, x + math.floor((w - gui.textWidth(label)) / 2), - y + math.floor((KEY_H - gui.fontHeight()) / 2), node.color, bottom) + gui.drawText(label, x + math.floor((w - gui.getTextWidth(label)) / 2), + y + math.floor((KEY_H - gui.getFontHeight()) / 2), node.color, bottom) end local function paintKeyboard(node) @@ -382,7 +384,7 @@ function on_tick() return end if mode == "connecting" then - local status = wifi.status() + local status = wifi.getStatus() if status.state == "connected" then message = "connected: " .. status.ip buildWifi() diff --git a/sdcard/lib/statusbar.lua b/sdcard/lib/statusbar.lua index 409dcd0..576e925 100644 --- a/sdcard/lib/statusbar.lua +++ b/sdcard/lib/statusbar.lua @@ -1,6 +1,9 @@ -- 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() +-- coordinates (the firmware drops the app viewport around the call), so gui.getWidth() -- here is the whole screen. +-- +-- Invalidation is entirely local: draw() compares what it is about to paint against what +-- it last painted, so the firmware never has to tell the bar that anything changed. local ui = require("ui") @@ -10,18 +13,31 @@ local PAD = 6 -- often it calls draw(). local M = {height = 22, interval = 1000} local BAR_H = M.height +local SIGNAL_W = 11 +local GAP = 8 + +-- What is currently on the panel. Every field repaints only when its value changes, so +-- the once-a-second tick costs one clock rectangle instead of a whole bar. +local shown = {} local function clock() - return sys.clockSynced() and os.date("%H:%M") or "--:--" + return sys.isClockSynced() and os.date("%H:%M:%S") or "--:--:--" +end + +local function signalBars() + local status = wifi.getStatus() + if status.state ~= "connected" then return 0 end + return status.rssi >= -60 and 3 or status.rssi >= -75 and 2 or 1 +end + +local function memPercent() + local free, total = sys.getMemory() + if not total or total <= 0 then return 0 end + return (total - free) * 100 // total 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 +local function drawSignal(x, y, bars, color, muted) 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) @@ -42,24 +58,62 @@ local function drawHome(theme) end end +-- Leaving fullscreen is the one change the bar cannot observe: no draw happens while an +-- app owns the panel, so the state it last painted still matches the state it would paint +-- now, while the pixels are gone. Apps toggle fullscreen through here for that reason. +function M.setFullscreen(on) + gui.setFullscreen(on) + if not on then shown = {} 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() - 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 w = gui.getWidth() + local textY = math.floor((BAR_H - 1 - gui.getFontHeight()) / 2) - local textY = math.floor((BAR_H - 1 - gui.fontHeight()) / 2) - local nameX = PAD - if home then - drawHome(theme) - nameX = BAR_H + PAD + -- Slots sized for their widest value, so a shorter one never shifts its neighbours. + local clockW, memW = gui.getTextWidth("00:00:00"), gui.getTextWidth("100%") + local clockX = w - PAD - clockW + local signalX = clockX - GAP - SIGNAL_W + local memX = signalX - GAP - memW + + -- Rotation moves every slot and the theme recolors them; a new app gets a fresh Lua + -- state, so an empty cache already means "repaint everything". + local key = gui.getRotation() .. "|" .. ui.themeName + if key ~= shown.key then + shown = {key = key} + 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 nameX = PAD + if home then + drawHome(theme) + nameX = BAR_H + PAD + end + gui.drawText(sys.getAppName(), nameX, textY, theme.fg, theme.bg) + end + + local mem = memPercent() + if mem ~= shown.mem then + shown.mem = mem + local text = mem .. "%" + gui.fillRect(memX, 0, memW, BAR_H - 1, theme.bg) + gui.drawText(text, memX + memW - gui.getTextWidth(text), textY, theme.muted, theme.bg) + end + + local bars = signalBars() + if bars ~= shown.bars then + shown.bars = bars + gui.fillRect(signalX, 0, SIGNAL_W, BAR_H - 1, theme.bg) + drawSignal(signalX, textY, bars, theme.fg, theme.muted) 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) - drawSignal(w - PAD - gui.textWidth(time) - 8 - 11, textY, theme.fg, theme.muted) + if time ~= shown.time then + shown.time = time + gui.fillRect(clockX, 0, clockW, BAR_H - 1, theme.bg) + gui.drawText(time, clockX, textY, theme.muted, theme.bg) + end end return M diff --git a/sdcard/lib/ui.lua b/sdcard/lib/ui.lua index c304d13..4bab05c 100644 --- a/sdcard/lib/ui.lua +++ b/sdcard/lib/ui.lua @@ -86,10 +86,18 @@ function ui.themeNames() return names end --- Re-read the saved theme. Called at load, and again by the settings app so a --- changed theme takes effect without relaunching. +-- Saves the theme and reloads the palette. The firmware stores only the name and cannot +-- call back into Lua, so this is the seam that keeps the two halves from drifting. +function ui.setTheme(name) + local ok = settings.setTheme(name) + ui.reloadTheme() + return ok +end + +-- Re-read the saved theme. Called at load, and by ui.setTheme(), so a changed theme takes +-- effect without relaunching. function ui.reloadTheme() - ui.themeName = sys.getTheme and sys.getTheme() or "light" + ui.themeName = settings.getTheme and settings.getTheme() or "light" local available = themes() local seed = available[ui.themeName] or available.light or FALLBACK ui.theme = palette(seed) @@ -319,8 +327,8 @@ end -- 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() + self.mw = resolve(self.w, available.w, "width") or gui.getTextWidth(self.label) + self.mh = resolve(self.h, available.h, "height") or gui.getFontHeight() return self.mw, self.mh end @@ -350,10 +358,10 @@ end -- 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 cols = gui.getWidth() >= gui.getHeight() 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 + local byWidth = (gui.getWidth() - 2 * pad - (cols - 1) * gap) // cols + local byHeight = (gui.getHeight() - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows return math.min(byWidth, byHeight), cols end @@ -363,11 +371,11 @@ end 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 + if spec.fit and gui.getTextWidth(text) > spec.fit then + while #text > 1 and gui.getTextWidth(text .. "~") > spec.fit do text = text:sub(1, -2) end text = text .. "~" end - spec.w, spec.fit = gui.textWidth(text), nil + spec.w, spec.fit = gui.getTextWidth(text), nil return ui.text(text, spec) end @@ -464,7 +472,7 @@ function ui.screen(root, style) end function Screen:relayout() - local rect = {x = 0, y = 0, w = gui.width(), h = gui.height()} + local rect = {x = 0, y = 0, w = gui.getWidth(), h = gui.getHeight()} self.root:measure(rect) self.root:place(rect) gui.clear(self.root.bg) @@ -473,7 +481,7 @@ end function Screen:draw() self.root:draw() -- Hold the pressed look briefly so a fast tap is still perceptible. - if self.released and sys.millis() - self.pressedAt >= PRESS_MS then + if self.released and sys.getMillis() - self.pressedAt >= PRESS_MS then self.released.pressed = false self.released:invalidate() self.released = nil @@ -484,7 +492,7 @@ function Screen:down(x, y) local target = self.root:hit(x, y) if not target then return end self.captured = target - self.pressedAt = sys.millis() + self.pressedAt = sys.getMillis() if target.press_style ~= false then target.pressed = true target:invalidate() diff --git a/src/gfx/statusbar.h b/src/gfx/statusbar.h deleted file mode 100644 index f8a2377..0000000 --- a/src/gfx/statusbar.h +++ /dev/null @@ -1,21 +0,0 @@ -#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.h b/src/lua/bindings.h index a4770b9..9f0ba9c 100644 --- a/src/lua/bindings.h +++ b/src/lua/bindings.h @@ -20,6 +20,7 @@ void bindApp(lua_State* L, LuaApp* owner); void registerGui(lua_State* L); void registerSys(lua_State* L); +void registerSettings(lua_State* L); void registerInput(lua_State* L); void registerFs(lua_State* L); void registerWifi(lua_State* L); diff --git a/src/lua/bindings/gui.cpp b/src/lua/bindings/gui.cpp index 867534b..720cba6 100644 --- a/src/lua/bindings/gui.cpp +++ b/src/lua/bindings/gui.cpp @@ -124,13 +124,23 @@ static int l_gui_drawText(lua_State* L) { return 0; } -// Only the current frame; sys.setRotation() is the persisted one. +// Only the current frame; settings.setRotation() is the persisted one. Degrees, matching +// settings: the 0-3 quarter turn index is TFT_eSPI's unit and stops at this boundary. static int l_gui_setRotation(lua_State* L) { - app(L)->tft.setRotation(luaL_checkinteger(L, 1)); + lua_Integer degrees = luaL_checkinteger(L, 1); + luaL_argcheck(L, degrees % 90 == 0 && degrees >= 0 && degrees <= 270, 1, "0, 90, 180 or 270"); + app(L)->tft.setRotation((degrees / 90) & 3); app(L)->applyViewport(); return 0; } +// The frame being drawn right now, which is not settings.getRotation(): an app may rotate +// the panel transiently, and chrome has to follow the pixels rather than the preference. +static int l_gui_getRotation(lua_State* L) { + lua_pushinteger(L, app(L)->tft.getRotation() * 90); + return 1; +} + // 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) { @@ -149,13 +159,13 @@ void registerGui(lua_State* L) { static const luaL_Reg lib[] = { // --- Panel width in pixels, for the current rotation. // @return integer - {"width", l_gui_width}, + {"getWidth", 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}, + {"setFullscreen", l_gui_fullscreen}, // --- Panel height in pixels, for the current rotation. // @return integer - {"height", l_gui_height}, + {"getHeight", l_gui_height}, // --- Fills the whole panel with one color. // @param color integer|nil Defaults to white. {"clear", l_gui_clear}, @@ -211,14 +221,17 @@ void registerGui(lua_State* L) { {"setTextSize", l_gui_setTextSize}, // --- Height of the current font in pixels, at the current text size. // @return integer - {"fontHeight", l_gui_fontHeight}, + {"getFontHeight", l_gui_fontHeight}, // --- Width the given text would occupy in pixels. // @param text string // @return integer - {"textWidth", l_gui_textWidth}, - // --- Rotates the frame for this draw only; sys.setRotation persists it. - // @param rotation integer 0 to 3, in quarter turns. + {"getTextWidth", l_gui_textWidth}, + // --- Rotates the frame for this draw only; settings.setRotation persists it. + // @param degrees integer 0, 90, 180 or 270. {"setRotation", l_gui_setRotation}, + // --- Rotation of the frame being drawn, which is not always the saved preference. + // @return integer Degrees clockwise. + {"getRotation", l_gui_getRotation}, // --- Packs 8 bit channels into the panel's RGB565 color format. // @param r integer // @param g integer diff --git a/src/lua/bindings/input.cpp b/src/lua/bindings/input.cpp index db8757e..0540414 100644 --- a/src/lua/bindings/input.cpp +++ b/src/lua/bindings/input.cpp @@ -47,7 +47,7 @@ void registerInput(lua_State* L) { {"getRawTouch", l_input_getRawTouch}, // --- Whether the panel is being touched. // @return boolean - {"touched", l_input_touched}, + {"isTouched", l_input_touched}, {nullptr, nullptr}}; luaL_newlib(L, lib); lua_setglobal(L, "input"); diff --git a/src/lua/bindings/settings.cpp b/src/lua/bindings/settings.cpp new file mode 100644 index 0000000..f7dbe50 --- /dev/null +++ b/src/lua/bindings/settings.cpp @@ -0,0 +1,111 @@ +// Persisted device preferences. The firmware is a first-class reader of these -- rotation +// and calibration are needed before any Lua state exists, and on every touch after that -- +// so C++ owns the store and Lua reaches it through here rather than parsing the file. +// +// Every setter persists *and* applies its effect, so no caller has to remember a second +// step. The one exception is the theme, whose palette lives in /lib/ui.lua and can only be +// reloaded from Lua; ui.setTheme() is the seam that pairs the two. + +#include "../../net.h" +#include "../../settings.h" +#include "../bindings.h" +#include "../lua_app.h" + +static int l_settings_getRotation(lua_State* L) { + lua_pushinteger(L, settings.rotation); + return 1; +} + +// Persisted, unlike gui.setRotation() which only changes the current frame. +static int l_settings_setRotation(lua_State* L) { + if (!settings.setRotation(luaL_checkinteger(L, 1))) { + lua_pushboolean(L, false); + return 1; + } + app(L)->tft.setRotation(settings.rotationIndex()); + app(L)->applyViewport(); + lua_pushboolean(L, settings.save()); + return 1; +} + +static int l_settings_getTheme(lua_State* L) { + lua_pushstring(L, settings.theme.c_str()); + return 1; +} + +// The firmware only stores the name; /lib/theme.lua decides what it looks like. +static int l_settings_setTheme(lua_State* L) { + size_t length; + const char* name = luaL_checklstring(L, 1, &length); + if (!length || length > 32) { + lua_pushboolean(L, false); + return 1; + } + settings.theme = String(name, length); + lua_pushboolean(L, settings.save()); + return 1; +} + +static int l_settings_getTimezone(lua_State* L) { + lua_pushstring(L, settings.timezone.c_str()); + return 1; +} + +// Takes a POSIX TZ rule, not a zone name: /lib/timezones.lua is only a picker, so a +// zone missing from that list is still reachable by writing the rule. +static int l_settings_setTimezone(lua_State* L) { + size_t length; + const char* tz = luaL_checklstring(L, 1, &length); + if (!length || length > 48) { + lua_pushboolean(L, false); + return 1; + } + settings.timezone = String(tz, length); + net::applyTimezone(); + lua_pushboolean(L, settings.save()); + return 1; +} + +static int l_settings_setCalibration(lua_State* L) { + settings.touchX0 = luaL_checkinteger(L, 1); + settings.touchY0 = luaL_checkinteger(L, 2); + settings.touchX1 = luaL_checkinteger(L, 3); + settings.touchY1 = luaL_checkinteger(L, 4); + lua_pushboolean(L, settings.save()); + return 1; +} + +void registerSettings(lua_State* L) { + static const luaL_Reg lib[] = { + // --- Saved screen rotation in degrees clockwise. + // @return integer + {"getRotation", l_settings_getRotation}, + // --- Rotates the screen and saves it. + // @param degrees integer 0, 90, 180 or 270. + // @return boolean Whether the setting was saved. + {"setRotation", l_settings_setRotation}, + // --- Name of the active theme in /lib/theme.lua. + // @return string + {"getTheme", l_settings_getTheme}, + // --- Saves the theme name. Apps call ui.setTheme(), which also reloads the palette. + // @param name string + // @return boolean Whether the setting was saved. + {"setTheme", l_settings_setTheme}, + // --- Active POSIX timezone rule. + // @return string + {"getTimezone", l_settings_getTimezone}, + // --- Sets the timezone from a POSIX TZ rule and saves it. + // @param tz string For example EST5EDT,M3.2.0,M11.1.0. + // @return boolean Whether the setting was saved. + {"setTimezone", l_settings_setTimezone}, + // --- Stores touch calibration, in the panel's unrotated frame. + // @param x0 integer Raw reading at the left edge. + // @param y0 integer Raw reading at the top edge. + // @param x1 integer Raw reading at the right edge. + // @param y1 integer Raw reading at the bottom edge. + // @return boolean Whether the setting was saved. + {"setCalibration", l_settings_setCalibration}, + {nullptr, nullptr}}; + luaL_newlib(L, lib); + lua_setglobal(L, "settings"); +} diff --git a/src/lua/bindings/sys.cpp b/src/lua/bindings/sys.cpp index 1aac526..d7a6454 100644 --- a/src/lua/bindings/sys.cpp +++ b/src/lua/bindings/sys.cpp @@ -1,7 +1,7 @@ -// Process control and persisted settings, plus the one-function `log` table. +// Process control and runtime state, plus the one-function `log` table. Persisted +// preferences live in the `settings` table, not here. #include "../../net.h" -#include "../../settings.h" #include "../bindings.h" #include "../lua_app.h" @@ -26,70 +26,6 @@ static int l_sys_launch(lua_State* L) { return 0; } -static int l_sys_getRotation(lua_State* L) { - lua_pushinteger(L, settings.rotation); - return 1; -} - -// Persisted, unlike gui.setRotation() which only changes the current frame. -static int l_sys_setRotation(lua_State* L) { - if (!settings.setRotation(luaL_checkinteger(L, 1))) { - lua_pushboolean(L, false); - return 1; - } - app(L)->tft.setRotation(settings.rotationIndex()); - app(L)->applyViewport(); - lua_pushboolean(L, settings.save()); - return 1; -} - -static int l_sys_getTheme(lua_State* L) { - lua_pushstring(L, settings.theme.c_str()); - return 1; -} - -// The firmware only stores the name; /lib/theme.lua decides what it looks like. -static int l_sys_setTheme(lua_State* L) { - size_t length; - const char* name = luaL_checklstring(L, 1, &length); - if (!length || length > 32) { - lua_pushboolean(L, false); - return 1; - } - settings.theme = String(name, length); - lua_pushboolean(L, settings.save()); - return 1; -} - -static int l_sys_getTimezone(lua_State* L) { - lua_pushstring(L, settings.timezone.c_str()); - return 1; -} - -// Takes a POSIX TZ rule, not a zone name: /lib/timezones.lua is only a picker, so a -// zone missing from that list is still reachable by writing the rule. -static int l_sys_setTimezone(lua_State* L) { - size_t length; - const char* tz = luaL_checklstring(L, 1, &length); - if (!length || length > 48) { - lua_pushboolean(L, false); - return 1; - } - settings.timezone = String(tz, length); - net::applyTimezone(); - lua_pushboolean(L, settings.save()); - return 1; -} - -static int l_sys_setCalibration(lua_State* L) { - settings.touchX0 = luaL_checkinteger(L, 1); - settings.touchY0 = luaL_checkinteger(L, 2); - settings.touchX1 = luaL_checkinteger(L, 3); - settings.touchY1 = luaL_checkinteger(L, 4); - lua_pushboolean(L, settings.save()); - return 1; -} - // Lua's os.time()/os.date() work off the same system clock, so this is the only // binding a clock UI needs: it says whether that clock means anything yet. static int l_sys_clockSynced(lua_State* L) { @@ -97,6 +33,12 @@ static int l_sys_clockSynced(lua_State* L) { return 1; } +static int l_sys_memory(lua_State* L) { + lua_pushinteger(L, ESP.getFreeHeap()); + lua_pushinteger(L, ESP.getHeapSize()); + return 2; +} + static int logAt(lua_State* L, const char* level) { Serial.printf("[lua:%s] %s\n", level, luaL_checkstring(L, 1)); return 0; @@ -137,7 +79,7 @@ void registerSys(lua_State* L) { static const luaL_Reg lib[] = { // --- Milliseconds since boot. // @return integer - {"millis", l_sys_millis}, + {"getMillis", l_sys_millis}, // --- Blocks for the given time. // @param ms integer {"delay", l_sys_delay}, @@ -145,44 +87,20 @@ void registerSys(lua_State* L) { {"exit", l_sys_exit}, // --- Directory name of the running app, for example "settings". // @return string - {"appName", l_sys_appName}, + {"getAppName", 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}, - // --- Saved screen rotation in degrees clockwise. - // @return integer - {"getRotation", l_sys_getRotation}, - // --- Rotates the screen and saves it. - // @param degrees integer 0, 90, 180 or 270. - // @return boolean Whether the setting was saved. - {"setRotation", l_sys_setRotation}, - // --- Name of the active theme in /lib/theme.lua. - // @return string - {"getTheme", l_sys_getTheme}, - // --- Selects a theme by name and saves it. - // @param name string - // @return boolean Whether the setting was saved. - {"setTheme", l_sys_setTheme}, - // --- Stores touch calibration, in the panel's unrotated frame. - // @param x0 integer Raw reading at the left edge. - // @param y0 integer Raw reading at the top edge. - // @param x1 integer Raw reading at the right edge. - // @param y1 integer Raw reading at the bottom edge. - // @return boolean Whether the setting was saved. - {"setCalibration", l_sys_setCalibration}, + // --- Free and total heap, in bytes. + // @return integer Free bytes. + // @return integer Total bytes. + {"getMemory", l_sys_memory}, // --- Whether SNTP has answered. Until it has, os.time() is only a build-time floor. // @return boolean - {"clockSynced", l_sys_clockSynced}, - // --- Active POSIX timezone rule. - // @return string - {"getTimezone", l_sys_getTimezone}, - // --- Sets the timezone from a POSIX TZ rule and saves it. - // @param tz string For example EST5EDT,M3.2.0,M11.1.0. - // @return boolean Whether the setting was saved. - {"setTimezone", l_sys_setTimezone}, + {"isClockSynced", l_sys_clockSynced}, {nullptr, nullptr}}; luaL_newlib(L, lib); lua_setglobal(L, "sys"); diff --git a/src/lua/bindings/wifi.cpp b/src/lua/bindings/wifi.cpp index 4b7bfdf..16fb336 100644 --- a/src/lua/bindings/wifi.cpp +++ b/src/lua/bindings/wifi.cpp @@ -124,13 +124,13 @@ void registerWifi(lua_State* L) { // --- Current connection state. // @return table Fields state, ssid, ip and rssi. state is one of disconnected, // --- connecting, connected, not_found or failed. - {"status", l_wifi_status}, + {"getStatus", l_wifi_status}, // --- Whether the station is associated. // @return boolean {"isConnected", l_wifi_isConnected}, // --- Current IPv4 address. // @return string The address, or 0.0.0.0 when not connected. - {"localIP", l_wifi_localIP}, + {"getLocalIP", l_wifi_localIP}, // --- Drops the connection but keeps the saved credentials. {"disconnect", l_wifi_disconnect}, // --- Drops the connection and erases the saved credentials. diff --git a/src/lua/lua_app.cpp b/src/lua/lua_app.cpp index d480808..96a449f 100644 --- a/src/lua/lua_app.cpp +++ b/src/lua/lua_app.cpp @@ -5,7 +5,6 @@ #include -#include "../gfx/statusbar.h" #include "../settings.h" #include "bindings.h" @@ -46,7 +45,14 @@ void LuaApp::mapTouch(const TS_Point& p, int16_t& x, int16_t& y) const { y -= barInset(); } -void LuaApp::applyViewport() { statusbar::apply(tft, 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; @@ -127,6 +133,9 @@ bool LuaApp::load(const char* path, bool isHome) { // 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)); @@ -157,17 +166,20 @@ static lua_Integer barField(lua_State* L, const char* key, lua_Integer fallback) // app, and there is only one of those per state. void LuaApp::loadStatusBar() { barBroken = false; - barHeight = statusbar::DEFAULT_H; + 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", statusbar::DEFAULT_H); + 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; @@ -197,6 +209,7 @@ void LuaApp::setTickInterval(uint32_t intervalMs) { void LuaApp::registerBindings() { registerGui(state); registerSys(state); + registerSettings(state); registerInput(state); registerFs(state); registerWifi(state); @@ -273,7 +286,7 @@ void LuaApp::loop() { if (!running()) return; } - if (barInset() > 0 && now >= nextBarMs) { + if (barInset() > 0 && !barBroken && now >= nextBarMs) { nextBarMs = now + barIntervalMs; drawStatusBar(); } diff --git a/src/lua/lua_app.h b/src/lua/lua_app.h index cca914d..9590dd9 100644 --- a/src/lua/lua_app.h +++ b/src/lua/lua_app.h @@ -69,7 +69,8 @@ class LuaApp { 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. + // log is the only place anyone would see it. The strip stays reserved either way, so a + // bar that dies mid-run never resizes the app underneath it. bool barBroken = false; int16_t barHeight = 0; uint32_t nextBarMs = 0; @@ -88,12 +89,13 @@ class LuaApp { uint32_t nextDrawMs = 0; static constexpr uint32_t DRAW_INTERVAL_MS = 33; - // Default only: /lib/statusbar.lua overrides it with an `interval` field. + // Defaults only: /lib/statusbar.lua overrides both with `interval` and `height` fields. static constexpr uint32_t BAR_INTERVAL_MS = 1000; + static constexpr int16_t DEFAULT_BAR_H = 22; // 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; } + int16_t barInset() const { return fullscreen ? 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. diff --git a/src/main.cpp b/src/main.cpp index b4dbb27..06662b8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,7 +4,6 @@ #include #include -#include "gfx/statusbar.h" #include "lua/lua_app.h" #include "net.h" #include "settings.h" @@ -50,7 +49,7 @@ 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); + tft.resetViewport(); // load() re-clips once the new app's bar reports its height Serial.printf("launching %s\n", path.c_str()); if (app.load(path.c_str(), path == HOME)) return; if (path == HOME) fallbackScreen("home failed to start"); diff --git a/stubs/slate32.lua b/stubs/slate32.lua index 710c98e..a4d1c63 100644 --- a/stubs/slate32.lua +++ b/stubs/slate32.lua @@ -38,15 +38,15 @@ gui = {} --- Panel width in pixels, for the current rotation. ---@return integer -function gui.width() end +function gui.getWidth() end --- Hands the app the whole panel, hiding the status bar, until it is turned off. ---@param on boolean -function gui.fullscreen(on) end +function gui.setFullscreen(on) end --- Panel height in pixels, for the current rotation. ---@return integer -function gui.height() end +function gui.getHeight() end --- Fills the whole panel with one color. ---@param color integer? Defaults to white. @@ -111,16 +111,20 @@ function gui.setTextSize(size) end --- Height of the current font in pixels, at the current text size. ---@return integer -function gui.fontHeight() end +function gui.getFontHeight() end --- Width the given text would occupy in pixels. ---@param text string ---@return integer -function gui.textWidth(text) end +function gui.getTextWidth(text) end ---- Rotates the frame for this draw only; sys.setRotation persists it. ----@param rotation integer 0 to 3, in quarter turns. -function gui.setRotation(rotation) end +--- Rotates the frame for this draw only; settings.setRotation persists it. +---@param degrees integer 0, 90, 180 or 270. +function gui.setRotation(degrees) end + +--- Rotation of the frame being drawn, which is not always the saved preference. +---@return integer Degrees clockwise. +function gui.getRotation() end --- Packs 8 bit channels into the panel's RGB565 color format. ---@param r integer @@ -197,7 +201,7 @@ function input.getRawTouch() end --- Whether the panel is being touched. ---@return boolean -function input.touched() end +function input.isTouched() end ---@class loglib log = {} @@ -214,12 +218,50 @@ function log.info(message) end ---@param message string function log.error(message) end +---@class settingslib +settings = {} + +--- Saved screen rotation in degrees clockwise. +---@return integer +function settings.getRotation() end + +--- Rotates the screen and saves it. +---@param degrees integer 0, 90, 180 or 270. +---@return boolean Whether the setting was saved. +function settings.setRotation(degrees) end + +--- Name of the active theme in /lib/theme.lua. +---@return string +function settings.getTheme() end + +--- Saves the theme name. Apps call ui.setTheme(), which also reloads the palette. +---@param name string +---@return boolean Whether the setting was saved. +function settings.setTheme(name) end + +--- Active POSIX timezone rule. +---@return string +function settings.getTimezone() end + +--- Sets the timezone from a POSIX TZ rule and saves it. +---@param tz string For example EST5EDT,M3.2.0,M11.1.0. +---@return boolean Whether the setting was saved. +function settings.setTimezone(tz) end + +--- Stores touch calibration, in the panel's unrotated frame. +---@param x0 integer Raw reading at the left edge. +---@param y0 integer Raw reading at the top edge. +---@param x1 integer Raw reading at the right edge. +---@param y1 integer Raw reading at the bottom edge. +---@return boolean Whether the setting was saved. +function settings.setCalibration(x0, y0, x1, y1) end + ---@class syslib sys = {} --- Milliseconds since boot. ---@return integer -function sys.millis() end +function sys.getMillis() end --- Blocks for the given time. ---@param ms integer @@ -230,7 +272,7 @@ function sys.exit() end --- Directory name of the running app, for example "settings". ---@return string -function sys.appName() end +function sys.getAppName() 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. @@ -240,44 +282,14 @@ function sys.setTickInterval(intervalMs) end ---@param path string Absolute path to the app's main.lua. function sys.launch(path) end ---- Saved screen rotation in degrees clockwise. ----@return integer -function sys.getRotation() end - ---- Rotates the screen and saves it. ----@param degrees integer 0, 90, 180 or 270. ----@return boolean Whether the setting was saved. -function sys.setRotation(degrees) end - ---- Name of the active theme in /lib/theme.lua. ----@return string -function sys.getTheme() end - ---- Selects a theme by name and saves it. ----@param name string ----@return boolean Whether the setting was saved. -function sys.setTheme(name) end - ---- Stores touch calibration, in the panel's unrotated frame. ----@param x0 integer Raw reading at the left edge. ----@param y0 integer Raw reading at the top edge. ----@param x1 integer Raw reading at the right edge. ----@param y1 integer Raw reading at the bottom edge. ----@return boolean Whether the setting was saved. -function sys.setCalibration(x0, y0, x1, y1) end +--- Free and total heap, in bytes. +---@return integer Free bytes. +---@return integer Total bytes. +function sys.getMemory() end --- Whether SNTP has answered. Until it has, os.time() is only a build-time floor. ---@return boolean -function sys.clockSynced() end - ---- Active POSIX timezone rule. ----@return string -function sys.getTimezone() end - ---- Sets the timezone from a POSIX TZ rule and saves it. ----@param tz string For example EST5EDT,M3.2.0,M11.1.0. ----@return boolean Whether the setting was saved. -function sys.setTimezone(tz) end +function sys.isClockSynced() end ---@class wifilib wifi = {} @@ -295,7 +307,7 @@ function wifi.connect(ssid, password) end --- Current connection state. --- connecting, connected, not_found or failed. ---@return table Fields state, ssid, ip and rssi. state is one of disconnected, -function wifi.status() end +function wifi.getStatus() end --- Whether the station is associated. ---@return boolean @@ -303,7 +315,7 @@ function wifi.isConnected() end --- Current IPv4 address. ---@return string The address, or 0.0.0.0 when not connected. -function wifi.localIP() end +function wifi.getLocalIP() end --- Drops the connection but keeps the saved credentials. function wifi.disconnect() end diff --git a/test/fake_device.lua b/test/fake_device.lua index 1ebec47..e823e45 100644 --- a/test/fake_device.lua +++ b/test/fake_device.lua @@ -22,7 +22,9 @@ local device = { networks = {}, -- what wifi.scan() returns status = {state = "disconnected", ssid = "", ip = "", rssi = 0}, painted = {}, -- every drawText call, in order - calibration = nil, -- last sys.setCalibration() + calibration = nil, -- last settings.setCalibration() + freeHeap = 200000, + totalHeap = 320000, connected = nil, -- last wifi.connect() exited = false, launched = nil, @@ -39,8 +41,8 @@ function device.install() gui = { color = function(r, g, b) return r * 65536 + g * 256 + b end, -- Rotation swaps the frame, exactly as TFT_eSPI reports it. - width = function() return device.rotation % 180 == 0 and 320 or 480 end, - height = function() return device.rotation % 180 == 0 and 480 or 320 end, + getWidth = function() return device.rotation % 180 == 0 and 320 or 480 end, + getHeight = function() return device.rotation % 180 == 0 and 480 or 320 end, clear = function() end, fillRect = function() end, drawRect = function() end, @@ -51,21 +53,27 @@ function device.install() device.painted[#device.painted + 1] = {label = label, x = x, y = y} 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, + getFontHeight = function() return device.fontHeight * device.textSize end, + getTextWidth = function(text) return #text * device.charWidth * device.textSize end, + setRotation = function(degrees) device.rotation = degrees end, + getRotation = function() return device.rotation end, + setFullscreen = function(on) device.fullscreen = on and true or false end, } sys = { - millis = function() return device.now end, + getMillis = function() return device.now end, exit = function() device.exited = true end, - appName = function() return device.appName end, + getAppName = function() return device.appName end, + getMemory = function() return device.freeHeap, device.totalHeap end, + isClockSynced = function() return device.clockSynced 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, + } + + settings = { getRotation = function() return device.rotation end, setRotation = function(degrees) if degrees % 90 ~= 0 or degrees < 0 or degrees > 270 then return false end @@ -75,13 +83,12 @@ function device.install() getTheme = function() return device.theme end, setTheme = function(name) device.theme = name return saved() end, setCalibration = function(...) device.calibration = {...} return saved() end, - clockSynced = function() return device.clockSynced end, getTimezone = function() return device.timezone end, setTimezone = function(tz) device.timezone = tz return saved() end, } input = { - touched = function() return device.raw ~= nil end, + isTouched = function() return device.raw ~= nil end, getTouch = function() if not device.raw then return nil end return device.raw[1], device.raw[2] @@ -102,7 +109,7 @@ function device.install() wifi = { scan = function() return device.networks end, - status = function() return device.status end, + getStatus = function() return device.status end, connect = function(ssid, password) device.connected = {ssid, password} return saved() diff --git a/test/settings_calibration.lua b/test/settings_calibration.lua index f0fa2a7..3ff2e9e 100644 --- a/test/settings_calibration.lua +++ b/test/settings_calibration.lua @@ -56,15 +56,15 @@ assert(fx0 > fx1, "flipped axis should descend") local zones = require("timezones") init() tapRow("timezone") -assert(sys.getTimezone() == zones[2].tz, "timezone " .. sys.getTimezone()) +assert(settings.getTimezone() == zones[2].tz, "timezone " .. settings.getTimezone()) tapRow("timezone") -assert(sys.getTimezone() == zones[3].tz, "timezone " .. sys.getTimezone()) +assert(settings.getTimezone() == zones[3].tz, "timezone " .. settings.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") - assert(sys.getRotation() == expected, "rotation " .. sys.getRotation()) + assert(settings.getRotation() == expected, "rotation " .. settings.getRotation()) end -- Calibration collects one sample per target and saves on the second release. diff --git a/test/statusbar_dirty.lua b/test/statusbar_dirty.lua new file mode 100644 index 0000000..f5694c1 --- /dev/null +++ b/test/statusbar_dirty.lua @@ -0,0 +1,59 @@ +-- Run: lua test/statusbar_dirty.lua +-- Asserts the bar repaints only what changed, and that it notices the two things it can +-- only learn by looking: rotation and theme. The clock is pinned unsynced so its text is +-- constant and every other field can be steered on its own. +package.path = "sdcard/lib/?.lua;test/?.lua;" .. package.path + +local device = require("fake_device").install() +local ui = require("ui") +local statusbar = require("statusbar") + +device.clockSynced = false + +-- Returns the labels drawn by one draw() call. +local function paint() + device.painted = {} + statusbar.draw(true) + local labels = {} + for _, entry in ipairs(device.painted) do labels[#labels + 1] = entry.label end + return labels +end + +local function has(labels, want) + for _, label in ipairs(labels) do if label == want then return true end end + return false +end + +local first = paint() +assert(has(first, device.appName), "first draw paints the app name") +assert(has(first, "--:--:--"), "first draw paints the clock") + +assert(#paint() == 0, "an unchanged bar paints nothing") + +device.freeHeap = device.freeHeap - 32000 +local memOnly = paint() +assert(#memOnly == 1 and memOnly[1]:find("%%"), "only the memory slot repaints") + +device.status = {state = "connected", ssid = "x", ip = "", rssi = -50} +assert(#paint() == 0, "signal bars are not text, so nothing is drawn") +device.status = {state = "disconnected", ssid = "", ip = "", rssi = 0} + +-- Rotation and theme invalidate everything, because every slot moves or recolors. +device.rotation = 180 +assert(has(paint(), device.appName), "rotation repaints the whole bar") +assert(#paint() == 0, "and settles again afterwards") + +device.theme = "dark" +ui.reloadTheme() +assert(has(paint(), device.appName), "a theme change repaints the whole bar") + +-- Leaving fullscreen is the one change draw() cannot see: the app painted over the bar +-- while nothing about the bar's own state moved. +device.theme = "light" +ui.reloadTheme() +paint() +statusbar.setFullscreen(true) +statusbar.setFullscreen(false) +assert(has(paint(), device.appName), "leaving fullscreen repaints the whole bar") + +print("ok")