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")