From 7bc9afae33c24fd43419b1069dd059325c6abcc4 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Wed, 5 Aug 2026 10:26:43 -0400 Subject: [PATCH] refactor(lua)!: migrate to the per-feature namespaces Follows lib/esp32-lua-api: gui -> screen, node -> tree, settings and input split into screen/sys/touch/buttons. Settings is one provider lighter, with timezone on Sys and rotation and theme on Gui, which now applies and persists a rotation in one call. The calibration screen stashes the rotation it borrows rather than relying on a transient setter. --- AGENTS.md | 36 +++++++------- lib/esp32-lua-api | 2 +- sdcard/.lua/apps/Hello/main.lua | 10 ++-- sdcard/.lua/apps/Home/main.lua | 2 +- sdcard/.lua/apps/Paint/main.lua | 6 +-- sdcard/.lua/apps/Settings/main.lua | 36 ++++++++------ sdcard/.lua/lib/keyboard.lua | 30 ++++++------ sdcard/.lua/lib/statusbar.lua | 28 +++++------ src/host/lua_host.cpp | 13 ++--- src/host/lua_host.h | 1 - src/host/providers.h | 20 ++------ src/host/providers_gui.cpp | 24 ++++++++- src/host/providers_sys.cpp | 29 +---------- src/main.cpp | 30 +++++++----- test/fake_device.lua | 79 +++++++++++++----------------- test/settings_calibration.lua | 10 ++-- test/statusbar_dirty.lua | 4 +- 17 files changed, 170 insertions(+), 190 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 972a50e..7996d81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,24 +20,24 @@ not here; `lua/api/**` there is generated from the C++ that registers it, so run ## Binding Conventions -Accessors are `getName` / `setName` / `isName`. Bare names are actions (`gui.fillRect`, -`wifi.scan`) or pure conversions (`gui.color`, `http.urlencode`). A missing getter is fine; +Accessors are `getName` / `setName` / `isName`. Bare names are actions (`screen.fillRect`, +`wifi.scan`) or pure conversions (`screen.color`, `http.urlencode`). A missing getter is fine; an accessor without a prefix is not. -Namespaces have one job each: `gui` device primitives and the live frame, `node` the widget -tree, `ui` widgets and palette, `settings` persisted preferences, `sys` process and runtime, -plus `wifi`/`http`/`fs`/`input`/`log`. `ble` uses NimBLE-Arduino because Bluedroid cannot +**Every namespace belongs to exactly one feature, or to core.** `screen` panel primitives and +its saved rotation and theme, `tree` the widget tree, `touch` the panel's input and its +calibration, `buttons` the button roles — all four are the `screen` and `touch`/`buttons` +features. Core is `sys` (process, runtime, timezone), `ui` widgets and palette, plus +`wifi`/`http`/`fs`/`log`/`timer`. `ble` uses NimBLE-Arduino because Bluedroid cannot initialize beside the Lua runtime; reserve `bt` and its full-ESP32 backend for future Classic Bluetooth. -**A setter that requires a follow-up call is a bug in the setter.** `settings.setTimezone()` -applies the TZ itself; `settings.setRotation()` applies the frame and re-clips. The one -unavoidable exception is the theme, because C cannot reload the Lua palette — `ui.setTheme()` -is the seam that pairs them: it writes through `settings.setTheme()`, then rebuilds the -palette and repaints. Apps call `ui.setTheme()`, never `settings.setTheme()`. - -**Persisted intent is not live state.** `settings.getRotation()` is what the user saved; -`gui.getRotation()` is the frame being drawn. They diverge on purpose while an app rotates -the panel transiently (touch calibration does). Never resolve one from the other. +**A setter that requires a follow-up call is a bug in the setter.** `sys.setTimezone()` +applies the TZ itself; `screen.setRotation()` rotates the panel, persists the choice and +re-clips, so there is one rotation rather than a live one and a saved one to reconcile. An app +that rotates the panel for its own purposes puts the old value back (touch calibration does). +The one unavoidable exception is the theme, because C cannot reload the Lua palette — +`ui.setTheme()` is the seam that pairs them: it writes through `screen.setTheme()`, then +rebuilds the palette and repaints. Apps call `ui.setTheme()`, never `screen.setTheme()`. Settings live in C++ (`src/settings.h`) because the firmware reads rotation and calibration before any `lua_State` exists, and calibration again on every touch. Lua reaches them through @@ -54,14 +54,14 @@ a node goes in a Lua table keyed by id, which is what `on_press` itself does. The split is by lifetime, and it is the whole design. `Node` holds what hit testing and repainting need forever. `Spec` holds what only `measure`/`place` read -- requested size, -pad, gap, alignment -- and is dropped by `node.dropScratch()` when layout ends. **Re-layout +pad, gap, alignment -- and is dropped by `tree.dropScratch()` when layout ends. **Re-layout rebuilds from Lua** (~8 ms, which nobody notices on a rotate) rather than retaining ~20 bytes a node against it. `measure` writes the measured size into `w`/`h` and `place` overwrites the same slots, because the two are never needed at once. Style is sparse and inherited: a role unset on a node is answered by the nearest ancestor that sets it, so a node naming no colours costs zero bytes. That is what keeps `Node` at 16. -Applying a palette is one `node.setStyle()` on a subtree root, which is how a dimmed region +Applying a palette is one `tree.setStyle()` on a subtree root, which is how a dimmed region and the lit dialog above it are one call each. **What is behind a node is derived, never set.** `bg` is the background a node offers its @@ -78,14 +78,14 @@ build are dead; update a live one with `ui.setText(id, text)`, which is also the between a clock tick and a rebuild. Layout has not run while a builder is running, so an app sizing itself to the frame reads -`ui.frame()`, not `gui.getHeight()` -- the panel is not the box the app was given. Whatever +`ui.frame()`, not `screen.getHeight()` -- the panel is not the box the app was given. Whatever mounts the tree sets the difference with `ui.setInset()`. Layout is `-Wall -Wextra` C++ free of Arduino headers, so `test/ui_layout_test.cpp` runs it on the host through `make test-cpp`. Adding a **primitive** (a paint routine, a layout mode) means C++ and a reflash; adding **composition** (`ui.confirm`, a new card) is still Lua on the SD card. Custom painting is the seam between them: a `custom` node paints itself through -the `gui` bindings. +the `screen` bindings. ## Apps diff --git a/lib/esp32-lua-api b/lib/esp32-lua-api index 445a9b2..75b3a2c 160000 --- a/lib/esp32-lua-api +++ b/lib/esp32-lua-api @@ -1 +1 @@ -Subproject commit 445a9b2b8fa2deaf122bb5ddc194b74841133bf0 +Subproject commit 75b3a2c4907bdcd0514501e849cab9b9fc2462bf diff --git a/sdcard/.lua/apps/Hello/main.lua b/sdcard/.lua/apps/Hello/main.lua index feaa05f..39aebdc 100644 --- a/sdcard/.lua/apps/Hello/main.lua +++ b/sdcard/.lua/apps/Hello/main.lua @@ -1,6 +1,6 @@ local ui = require "ui" -local FONT = gui.FONT_UI +local FONT = screen.FONT_UI local M = {} local seconds = 0 @@ -15,15 +15,15 @@ function M.init() seconds = seconds + 1 ui.setText(uptime, text()) end) - log.info("hello app started, screen " .. gui.getWidth() .. "x" .. gui.getHeight()) + log.info("hello app started, screen " .. screen.getWidth() .. "x" .. screen.getHeight()) end function M.node() -- A fixed slot for the clock, because a node keeps the box it was measured with and the -- text grows a digit at a time. uptime = ui.text(text(), { - w = gui.getTextWidth(FONT, "uptime: 00000s"), - h = gui.getFontHeight(FONT), + w = screen.getTextWidth(FONT, "uptime: 00000s"), + h = screen.getFontHeight(FONT), font = FONT, }) return ui.box { @@ -41,7 +41,7 @@ end -- tree would need one node each to remember them. function M.on_touch(x, y) log.info("touch at " .. x .. ", " .. y) - gui.fillCircle(x, y, 6, ui.theme.accent) + screen.fillCircle(x, y, 6, ui.theme.accent) end return M diff --git a/sdcard/.lua/apps/Home/main.lua b/sdcard/.lua/apps/Home/main.lua index ef4c89f..3c467b7 100644 --- a/sdcard/.lua/apps/Home/main.lua +++ b/sdcard/.lua/apps/Home/main.lua @@ -14,7 +14,7 @@ local function card(name, side) on_click = function() sys.launch(name) end, - ui.label(name, { font = gui.FONT_UI, fit = side - 16 }), + ui.label(name, { font = screen.FONT_UI, fit = side - 16 }), } end diff --git a/sdcard/.lua/apps/Paint/main.lua b/sdcard/.lua/apps/Paint/main.lua index f8058d5..65874c9 100644 --- a/sdcard/.lua/apps/Paint/main.lua +++ b/sdcard/.lua/apps/Paint/main.lua @@ -51,7 +51,7 @@ function M.on_touch_down(x, y) return end lastX, lastY = x, y - gui.fillCircle(x, y, BRUSH, ui.theme.accent) + screen.fillCircle(x, y, BRUSH, ui.theme.accent) end -- Strokes are joined with a line because the poll rate, not the finger, decides the gap: @@ -60,8 +60,8 @@ function M.on_touch_move(x, y) if not lastX or not inside(x, y) then return end - gui.drawLine(lastX, lastY, x, y, ui.theme.accent) - gui.fillCircle(x, y, BRUSH, ui.theme.accent) + screen.drawLine(lastX, lastY, x, y, ui.theme.accent) + screen.fillCircle(x, y, BRUSH, ui.theme.accent) lastX, lastY = x, y end diff --git a/sdcard/.lua/apps/Settings/main.lua b/sdcard/.lua/apps/Settings/main.lua index b3ab16b..8829715 100644 --- a/sdcard/.lua/apps/Settings/main.lua +++ b/sdcard/.lua/apps/Settings/main.lua @@ -2,7 +2,7 @@ local ui = require "ui" local statusbar = require "statusbar" local zones = require "timezones" -local FONT = gui.FONT_UI +local FONT = screen.FONT_UI local INSET = 30 local MENU_PAD, MENU_GAP = 12, 8 @@ -21,6 +21,7 @@ local bleScanRequested, bleConnectRequested = false, false local selectedNetwork, password = nil, "" local selectedBleDevice local targetIndex = 1 +local rotationBeforeCalibration = 0 local passwordLabel local networkOf, bleDeviceOf = {}, {} @@ -51,32 +52,35 @@ end local function paintTarget(_, ox, oy) local x, y = target(targetIndex) local theme = ui.theme - gui.drawText(FONT, ox + 10, oy + 10, "tap the cross", theme.color, nil, theme.background) - gui.drawLine(x - 12, y, x + 12, y, theme.accent) - gui.drawLine(x, y - 12, x, y + 12, theme.accent) + screen.drawText(FONT, ox + 10, oy + 10, "tap the cross", theme.color, nil, theme.background) + screen.drawLine(x - 12, y, x + 12, y, theme.accent) + screen.drawLine(x, y - 12, x, y + 12, theme.accent) end local function finishCalibration() - local ok = settings.setCalibration(M.computeCalibration(samples[1], samples[2], 320, 480, INSET)) + local ok = touch.setCalibration(M.computeCalibration(samples[1], samples[2], 320, 480, INSET)) message = ok and "calibration saved" or "save failed" - gui.setRotation(settings.getRotation()) + screen.setRotation(rotationBeforeCalibration) mode = "menu" statusbar.setFullscreen(false) -- rebuilds, which is what puts the menu back end +-- Rotation is saved as it is applied, so calibrating upright is a change the app +-- has to put back itself once the samples are in. local function startCalibration() message = nil samples, pending, armed = {}, nil, false targetIndex = 1 mode = "calibrate" - gui.setRotation(0) + rotationBeforeCalibration = screen.getRotation() + screen.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. statusbar.setFullscreen(true) end local function cycleRotation() - local ok = settings.setRotation((settings.getRotation() + 90) % 360) + local ok = screen.setRotation((screen.getRotation() + 90) % 360) message = ok and "rotation saved" or "save failed" ui.rebuild() end @@ -97,7 +101,7 @@ end -- 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 = settings.getTimezone() + local current = sys.getTimezone() for _, zone in ipairs(zones) do if zone.tz == current then return zone.name @@ -107,14 +111,14 @@ local function zoneLabel() end local function cycleTimezone() - local current = settings.getTimezone() + local current = sys.getTimezone() local next_index = 1 for index, zone in ipairs(zones) do if zone.tz == current then next_index = index % #zones + 1 end end - message = settings.setTimezone(zones[next_index].tz) and nil or "save failed" + message = sys.setTimezone(zones[next_index].tz) and nil or "save failed" ui.rebuild() end @@ -236,10 +240,10 @@ local function card(side, title, value, on_click) justify = "center", align = "center", on_click = on_click, - ui.label(title, { font = gui.FONT_UI, fit = side - 12 }), + ui.label(title, { font = screen.FONT_UI, fit = side - 12 }), } if value then - spec[#spec + 1] = ui.label(value, { font = gui.FONT_SMALL, fit = side - 12 }) + spec[#spec + 1] = ui.label(value, { font = screen.FONT_SMALL, fit = side - 12 }) end return ui.button(spec) end @@ -250,7 +254,7 @@ local function menuScreen() 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", settings.getRotation() .. " deg", cycleRotation), + card(side, "Rotation", screen.getRotation() .. " deg", cycleRotation), card(side, "WiFi", wifiValue(wifi.getStatus()), function() show "wifi" end), @@ -356,7 +360,7 @@ local function bleScreen() local initialized = ble.isInitialized() local items = { pad = 12, gap = 7, ui.text "BLE", ui.text(initialized and "on" or "off") } if bleMessage then - items[#items + 1] = ui.text(bleMessage, { font = gui.FONT_SMALL }) + items[#items + 1] = ui.text(bleMessage, { font = screen.FONT_SMALL }) end if initialized then items[#items + 1] = ui.button { label = "scan devices", on_click = requestBleScan } @@ -525,7 +529,7 @@ function M.tick() return end - local rx, ry = input.getRawTouch() + local rx, ry = touch.getRawPoint() if not armed then if not rx then armed = true diff --git a/sdcard/.lua/lib/keyboard.lua b/sdcard/.lua/lib/keyboard.lua index 88fb4cb..200c98b 100644 --- a/sdcard/.lua/lib/keyboard.lua +++ b/sdcard/.lua/lib/keyboard.lua @@ -1,5 +1,5 @@ local ui = require "ui" -local FONT = gui.FONT_UI +local FONT = screen.FONT_UI local M = {} @@ -106,13 +106,13 @@ end local function drawArrow(x, y, width, color, down) local middle = x + width // 2 if down then - gui.drawLine(middle, y + 8, middle, y + 20, color) - gui.drawLine(middle - 5, y + 15, middle, y + 20, color) - gui.drawLine(middle, y + 20, middle + 5, y + 15, color) + screen.drawLine(middle, y + 8, middle, y + 20, color) + screen.drawLine(middle - 5, y + 15, middle, y + 20, color) + screen.drawLine(middle, y + 20, middle + 5, y + 15, color) else - gui.drawLine(middle, y + 9, middle, y + 21, color) - gui.drawLine(middle - 5, y + 14, middle, y + 9, color) - gui.drawLine(middle, y + 9, middle + 5, y + 14, color) + screen.drawLine(middle, y + 9, middle, y + 21, color) + screen.drawLine(middle - 5, y + 14, middle, y + 9, color) + screen.drawLine(middle, y + 9, middle + 5, y + 14, color) end end @@ -120,14 +120,14 @@ local function drawKey(id, label, x, y, width, pressed) local theme = ui.theme local face = pressed and theme.pressedFace or theme.face local color = pressed and theme.pressedColor or theme.color - gui.roundRect(x, y, width, KEY_H, theme.radius, theme.background, face, nil, color) + screen.roundRect(x, y, width, KEY_H, theme.radius, theme.background, face, nil, color) if label == "shift" then drawArrow(x, y, width, color, state[id].page == "upper") else - gui.drawText( + screen.drawText( FONT, - x + (width - gui.getTextWidth(FONT, label)) // 2, - y + (KEY_H - gui.getFontHeight(FONT)) // 2, + x + (width - screen.getTextWidth(FONT, label)) // 2, + y + (KEY_H - screen.getFontHeight(FONT)) // 2, label, color, nil, @@ -172,7 +172,7 @@ function M.eachKey(page, rect, visit) end local function rectOf(id) - local x, y, w, h = node.getRect(id) + local x, y, w, h = tree.getRect(id) return { x = x, y = y, w = w, h = h } end @@ -230,13 +230,13 @@ local function press(id, x, y) end elseif action == "shift" then st.page = st.page == "lower" and "upper" or "lower" - node.invalidate(id) + tree.invalidate(id) elseif action == "symbols" then st.page = st.page == "numbers" and "symbols" or "numbers" - node.invalidate(id) + tree.invalidate(id) elseif action == "mode" then st.page = (st.page == "lower" or st.page == "upper") and "numbers" or "lower" - node.invalidate(id) + tree.invalidate(id) elseif action == "backspace" then st.value = st.value:sub(1, -2) changed() diff --git a/sdcard/.lua/lib/statusbar.lua b/sdcard/.lua/lib/statusbar.lua index 379edf4..df1b610 100644 --- a/sdcard/.lua/lib/statusbar.lua +++ b/sdcard/.lua/lib/statusbar.lua @@ -6,10 +6,10 @@ local ui = require "ui" -local FONT = gui.FONT_SMALL +local FONT = screen.FONT_SMALL -- The app name reads as a heading, matching a card title; the clock and memory -- stay small so the right-hand stack still fits two rows. -local TITLE_FONT = gui.FONT_UI +local TITLE_FONT = screen.FONT_UI local BAR_H = 44 local PAD, GAP = 6, 8 @@ -44,10 +44,10 @@ local function paintWifi(_, x, y) local bars, offline = signalBars() local empty = offline and theme.disabled or theme.muted for i, h in ipairs(WIFI_HEIGHTS) do - gui.fillRect(x + (i - 1) * 4, y + 8 - h, 3, h, i <= bars and theme.color or empty) + screen.fillRect(x + (i - 1) * 4, y + 8 - h, 3, h, i <= bars and theme.color or empty) end if offline then - gui.drawLine(x, y, x + WIFI_W - 1, y + 8, theme.muted) + screen.drawLine(x, y, x + WIFI_W - 1, y + 8, theme.muted) end end @@ -55,14 +55,14 @@ end -- this size, and a custom node keeps the press feedback to one repaint of one box. local function paintBack(id, x, y, w, h) local theme = ui.theme - local pressed = node.isPressed(id) + local pressed = tree.isPressed(id) local face = pressed and theme.pressedFace or theme.face local color = pressed and theme.pressedColor or theme.color - gui.roundRect(x, y, w, h, 4, theme.background, face) + screen.roundRect(x, y, w, h, 4, theme.background, face) local cx, cy = x + w // 2 + 1, y + h // 2 for offset = 0, 1 do -- two passes, because a one pixel chevron reads as a speck - gui.drawLine(cx + offset, cy - 4, cx + offset - 4, cy, color) - gui.drawLine(cx + offset - 4, cy, cx + offset, cy + 4, color) + screen.drawLine(cx + offset, cy - 4, cx + offset - 4, cy, color) + screen.drawLine(cx + offset - 4, cy, cx + offset, cy + 4, color) end end @@ -74,11 +74,11 @@ local function backNode() paint = paintBack, press_style = false, -- paintBack draws its own, so nothing else should repaint on_enter = function(id) - node.setPressed(id, true) + tree.setPressed(id, true) ui.invalidate(id) end, on_exit = function(id) - node.setPressed(id, false) + tree.setPressed(id, false) ui.invalidate(id) end, on_click = function() @@ -91,9 +91,9 @@ end function M.node() local theme = ui.theme title = sys.getAppTitle() - local clockW = gui.getTextWidth(FONT, "00:00:00") - local memW = gui.getTextWidth(FONT, "0000kB") - local fontH = gui.getFontHeight(FONT) + local clockW = screen.getTextWidth(FONT, "00:00:00") + local memW = screen.getTextWidth(FONT, "0000kB") + local fontH = screen.getFontHeight(FONT) ids = {} ids.clock = ui.text(clock(), { w = clockW, h = fontH, font = FONT, color = theme.muted }) @@ -105,7 +105,7 @@ function M.node() -- width, and the three slots have to share it. local statusW = math.max(clockW, memW + GAP + WIFI_W) local backW = sys.canGoBack() and BAR_H - 6 or 0 - local titleW = gui.getWidth() - 2 * PAD - 2 * GAP - backW - statusW + local titleW = screen.getWidth() - 2 * PAD - 2 * GAP - backW - statusW local status = ui.box { w = statusW, diff --git a/src/host/lua_host.cpp b/src/host/lua_host.cpp index d710e52..fbcef9c 100644 --- a/src/host/lua_host.cpp +++ b/src/host/lua_host.cpp @@ -2,13 +2,9 @@ #include "../settings.h" -// Called from the initializer list once every provider member exists, which -// declaration order guarantees: the runtime holds pointers to them for its -// whole life. esp32lua::Providers LuaHost::wire() { esp32lua::Providers providers; providers.log = &logProvider; - providers.settings = &settingsProvider; providers.sys = &sysProvider; providers.fs = &fsProvider; providers.gui = &guiProvider; @@ -17,14 +13,12 @@ esp32lua::Providers LuaHost::wire() { providers.wifi = &wifiProvider; providers.ble = &bleProvider; providers.touch = &touchProvider; - // No buttons on this board, so sys.hasFeature("buttons") is false and input - // gains nothing. return providers; } LuaHost::LuaHost(TFT_eSPI& tft, XPT2046_Touchscreen& touch) - : tft(tft), touchPanel(touch), settingsProvider(*this), - guiProvider(tft, *this), touchProvider(touch, *this), runtime(wire()) {} + : tft(tft), touchPanel(touch), guiProvider(tft, *this), + touchProvider(touch, *this), runtime(wire()) {} void LuaHost::mapTouch(const TS_Point& point, int16_t& x, int16_t& y) const { const int16_t nx = @@ -67,8 +61,7 @@ bool LuaHost::begin() { } void LuaHost::prepareForApp() { - tft.setRotation( - settings.rotationIndex()); // the previous app may have rotated the frame + tft.setRotation(settings.rotationIndex()); lastTouched = true; // the tap that launched this app may still be down ignoreRelease = true; // and its release is not this app's gesture Serial.printf("[lua] launching free=%u largest=%u\n", ESP.getFreeHeap(), diff --git a/src/host/lua_host.h b/src/host/lua_host.h index f33ecfd..0f2bd68 100644 --- a/src/host/lua_host.h +++ b/src/host/lua_host.h @@ -44,7 +44,6 @@ private: XPT2046_Touchscreen& touchPanel; slate::Log logProvider; - slate::Settings settingsProvider; slate::Sys sysProvider; slate::Fs fsProvider; slate::Gui guiProvider; diff --git a/src/host/providers.h b/src/host/providers.h index 4fa0030..5135600 100644 --- a/src/host/providers.h +++ b/src/host/providers.h @@ -21,25 +21,13 @@ public: void write(esp32lua::LogLevel level, const std::string& message) override; }; -class Settings : public esp32lua::SettingsProvider { -public: - explicit Settings(LuaHost& host) : host(host) {} - int32_t rotation() const override; - esp32lua::Status setRotation(int32_t degrees) override; - std::string timezone() const override; - esp32lua::Status setTimezone(const std::string& timezone) override; - std::string theme() const override; - esp32lua::Status setTheme(const std::string& theme) override; - -private: - LuaHost& host; -}; - class Sys : public esp32lua::SysProvider { public: int32_t millis() const override; esp32lua::MemoryInfo memory() const override; bool isClockSynced() const override; + std::string timezone() const override; + esp32lua::Status setTimezone(const std::string& timezone) override; }; class Fs : public esp32lua::FsProvider { @@ -77,7 +65,9 @@ public: int32_t width() const override; int32_t height() const override; int32_t rotation() const override; - void setRotation(int32_t degrees) override; + esp32lua::Status setRotation(int32_t degrees) override; + std::string theme() const override; + esp32lua::Status setTheme(const std::string& theme) override; int32_t color(int32_t r, int32_t g, int32_t b) const override; void clear(int32_t color) override; void fillRect(int32_t x, int32_t y, int32_t w, int32_t h, diff --git a/src/host/providers_gui.cpp b/src/host/providers_gui.cpp index ca11694..f1187a0 100644 --- a/src/host/providers_gui.cpp +++ b/src/host/providers_gui.cpp @@ -2,6 +2,7 @@ #include #include "../gfx/round_rect.h" +#include "../settings.h" #include "lua_host.h" #include "providers.h" @@ -127,7 +128,28 @@ int32_t Gui::width() const { return tft.width(); } int32_t Gui::height() const { return tft.height(); } int32_t Gui::rotation() const { return tft.getRotation() * 90; } -void Gui::setRotation(int32_t degrees) { tft.setRotation((degrees / 90) & 3); } +// Applies the rotation and persists it in one call, because a setter that needs +// a follow-up is a bug in the setter: the frame, the viewport and the bar all +// move together or not at all. +esp32lua::Status Gui::setRotation(int32_t degrees) { + if (!settings.setRotation(degrees)) + return esp32lua::Status::failure("rotation must be 0, 90, 180 or 270"); + host.applyRotation(); + return settings.save() ? esp32lua::Status::success() + : esp32lua::Status::failure("cannot save settings"); +} + +std::string Gui::theme() const { return settings.theme.c_str(); } + +// Stores the name only. ui.setTheme() owns applying it, because the palette and +// the repaint it drives exist solely in Lua. +esp32lua::Status Gui::setTheme(const std::string& theme) { + if (theme.empty() || theme.size() > 16) + return esp32lua::Status::failure("theme must be 1 to 16 characters"); + settings.theme = theme.c_str(); + return settings.save() ? esp32lua::Status::success() + : esp32lua::Status::failure("cannot save settings"); +} int32_t Gui::color(int32_t r, int32_t g, int32_t b) const { return tft.color565(r, g, b); diff --git a/src/host/providers_sys.cpp b/src/host/providers_sys.cpp index c2aa3cc..df58e7a 100644 --- a/src/host/providers_sys.cpp +++ b/src/host/providers_sys.cpp @@ -18,22 +18,9 @@ void Log::write(esp32lua::LogLevel level, const std::string& message) { Serial.printf("[lua] %s: %s\n", tag, message.c_str()); } -int32_t Settings::rotation() const { return settings.rotation; } +std::string Sys::timezone() const { return settings.timezone.c_str(); } -// Applies the rotation itself, because a setter that needs a follow-up call is -// a bug in the setter: the frame, the viewport and the bar all move together or -// not at all. -Status Settings::setRotation(int32_t degrees) { - if (!settings.setRotation(degrees)) - return Status::failure("rotation must be 0, 90, 180 or 270"); - host.applyRotation(); - return settings.save() ? Status::success() - : Status::failure("cannot save settings"); -} - -std::string Settings::timezone() const { return settings.timezone.c_str(); } - -Status Settings::setTimezone(const std::string& timezone) { +Status Sys::setTimezone(const std::string& timezone) { if (timezone.empty() || timezone.size() > 48) return Status::failure("timezone must be 1 to 48 characters"); settings.timezone = timezone.c_str(); @@ -42,18 +29,6 @@ Status Settings::setTimezone(const std::string& timezone) { : Status::failure("cannot save settings"); } -std::string Settings::theme() const { return settings.theme.c_str(); } - -// Stores the name only. ui.setTheme() owns applying it, because the palette and -// the repaint it drives exist solely in Lua. -Status Settings::setTheme(const std::string& theme) { - if (theme.empty() || theme.size() > 16) - return Status::failure("theme must be 1 to 16 characters"); - settings.theme = theme.c_str(); - return settings.save() ? Status::success() - : Status::failure("cannot save settings"); -} - int32_t Sys::millis() const { return static_cast(::millis()); } esp32lua::MemoryInfo Sys::memory() const { diff --git a/src/main.cpp b/src/main.cpp index 889ff92..c064298 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,7 +1,3 @@ -// Board bring-up and the loop. Everything about running a Lua app -- the state, -// the bindings, app loading, navigation history -- lives in the shared runtime -// behind LuaHost. - #include #include #include @@ -31,10 +27,10 @@ LuaHost host(tft, touch); static bool halted = false; static void fallbackScreen(const char* message) { - tft.resetViewport(); // no app, no status bar: this message owns the panel + tft.resetViewport(); tft.setRotation(0); tft.fillScreen(TFT_WHITE); - tft.setTextSize(2); + tft.setTextSize(1); tft.setTextColor(TFT_RED, TFT_WHITE); tft.drawString(message, 10, 10); tft.setTextColor(TFT_BLACK, TFT_WHITE); @@ -43,7 +39,7 @@ static void fallbackScreen(const char* message) { halted = true; } -// ponytail: fixed 10s / 8% dim, no fade. Make it a setting when someone asks. +// Auto Dim constexpr uint32_t kDimAfterMs = 10000; constexpr int kBacklightChannel = 0; uint32_t lastTouchMs = 0; @@ -62,8 +58,7 @@ void updateBacklight() { void setup() { Serial.begin(115200); - // A failed new otherwise unwinds to terminate() and a bare abort backtrace. - // Nothing can be freed at that point, so this only buys a legible cause. + // OOM Logging std::set_new_handler([]() { logHeap("oom"); Serial.println("[fatal] out of memory"); @@ -71,41 +66,52 @@ void setup() { ESP.restart(); }); + // Start TFT tft.begin(); tft.setRotation(0); tft.fillScreen(TFT_WHITE); touchSpi.begin(14, 12, 13, TOUCH_CS); touch.begin(touchSpi); - // TFT_eSPI leaves TFT_BL a plain output; LEDC takes it over for dimming. + // TFT Dimming ledcSetup(kBacklightChannel, 5000, 8); ledcAttachPin(TFT_BL, kBacklightChannel); ledcWrite(kBacklightChannel, 255); lastTouchMs = millis(); + // SD Card sdSpi.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS); if (!SD.begin(SD_CS, sdSpi)) { fallbackScreen("SD card mount failed"); return; } - settings.load(); // absent file keeps the built-in defaults + // Load Settings & Load Lua + settings.load(); net::begin(); if (!host.begin()) fallbackScreen("home failed to start"); + + // Start WiFi net::startWifi(); } void loop() { + // Halted if (halted) { delay(100); return; } + + // Auto Dim & Net / Lua Loop updateBacklight(); net::loop(); host.loop(); - // The launcher failing to start is the one error no app can recover from. + + // Failed Lua Main if (!host.running()) fallbackScreen("home failed to start"); + + // Yield delay(1); } diff --git a/test/fake_device.lua b/test/fake_device.lua index fa1fdd7..6268561 100644 --- a/test/fake_device.lua +++ b/test/fake_device.lua @@ -23,7 +23,7 @@ local device = { bleDevices = {}, -- what ble.scan() returns status = { state = "disconnected", ssid = "", ip = "", rssi = 0 }, painted = {}, -- every drawText call, in order - calibration = nil, -- last settings.setCalibration() + calibration = nil, -- last touch.setCalibration() freeHeap = 200000, totalHeap = 320000, largestBlock = 100000, @@ -56,7 +56,7 @@ function device.install() device.invalidated = {} device.tapTarget = nil - node = setmetatable({ + tree = setmetatable({ reset = function() for index in ipairs(nodes) do nodes[index] = nil @@ -118,7 +118,7 @@ function device.install() end, }) - gui = { + screen = { -- Font roles are scales of the one built-in font, matching the firmware's GuiProvider. FONT_SMALL = 1, FONT_UI = 2, @@ -162,11 +162,22 @@ function device.install() return #text * device.charWidth * (font or 1) end, setRotation = function(degrees) + if degrees % 90 ~= 0 or degrees < 0 or degrees > 270 then + return false + end device.rotation = degrees + return saved() end, getRotation = function() return device.rotation end, + getTheme = function() + return device.theme + end, + setTheme = function(name) + device.theme = name + return saved() + end, } sys = { @@ -195,7 +206,7 @@ function device.install() return 1 end, hasFeature = function(name) - return name == "touch" + return name == "touch" or name == "screen" end, getMemory = function() return device.freeHeap, device.totalHeap, device.largestBlock @@ -203,6 +214,13 @@ function device.install() isClockSynced = function() return device.clockSynced end, + getTimezone = function() + return device.timezone + end, + setTimezone = function(tz) + device.timezone = tz + return saved() + end, launch = function(path, arg) device.launched = { path = path, arg = arg, replace = false } end, @@ -211,53 +229,26 @@ function device.install() end, } - settings = { - getRotation = function() - return device.rotation + touch = { + isTouched = function() + return device.raw ~= nil end, - setRotation = function(degrees) - if degrees % 90 ~= 0 or degrees < 0 or degrees > 270 then - return false + getPoint = function() + if not device.raw then + return nil end - device.rotation = degrees - return saved() + return device.raw[1], device.raw[2] + end, + getRawPoint = function() + if not device.raw then + return nil + end + return device.raw[1], device.raw[2] end, setCalibration = function(...) device.calibration = { ... } return saved() end, - getTimezone = function() - return device.timezone - end, - setTimezone = function(tz) - device.timezone = tz - return saved() - end, - getTheme = function() - return device.theme - end, - setTheme = function(name) - device.theme = name - return saved() - end, - } - - input = { - isTouched = function() - return device.raw ~= nil - end, - getTouch = function() - if not device.raw then - return nil - end - return device.raw[1], device.raw[2] - end, - getRawTouch = function() - if not device.raw then - return nil - end - return device.raw[1], device.raw[2] - end, } fs = { diff --git a/test/settings_calibration.lua b/test/settings_calibration.lua index f94fe3a..50af3e1 100644 --- a/test/settings_calibration.lua +++ b/test/settings_calibration.lua @@ -41,20 +41,20 @@ assert(fx0 > fx1, "flipped axis should descend") local zones = require "timezones" restart() tapRow "Timezone" -assert(settings.getTimezone() == zones[2].tz, "timezone " .. settings.getTimezone()) +assert(sys.getTimezone() == zones[2].tz, "timezone " .. sys.getTimezone()) -- Cycling rebuilds the screen, which is the only way a screen changes now, so the card -- shows the new zone and the menu is still the same size. -local beforeCycle = node.getCount() +local beforeCycle = tree.getCount() assert(device.labelled(zones[2].name), "timezone card did not take its new value") -assert(node.getCount() == beforeCycle, "the rebuilt menu grew") +assert(tree.getCount() == beforeCycle, "the rebuilt menu grew") tapRow "Timezone" -assert(settings.getTimezone() == zones[3].tz, "timezone " .. settings.getTimezone()) +assert(sys.getTimezone() == zones[3].tz, "timezone " .. sys.getTimezone()) -- Rotation cycles through the four quarter turns and wraps back to 0. restart() for _, expected in ipairs { 90, 180, 270, 0 } do tapRow "Rotation" - assert(settings.getRotation() == expected, "rotation " .. settings.getRotation()) + assert(screen.getRotation() == expected, "rotation " .. screen.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 index a1bf9f0..b77dc48 100644 --- a/test/statusbar_dirty.lua +++ b/test/statusbar_dirty.lua @@ -20,7 +20,7 @@ local function screen() end ui.mount(screen) -local built = node.getCount() +local built = tree.getCount() assert(device.labelled "--:--:--", "the bar built no clock") assert(device.labelled "117kB", "the bar built no memory slot") assert(device.labelled(device.appName), "the bar built no title") @@ -36,7 +36,7 @@ assert(tick() == 0, "an unchanged bar repaints nothing") device.freeHeap = device.freeHeap - 32000 assert(tick() == 1, "only the memory slot repaints") assert(device.labelled "148kB", "the memory slot kept its old text") -assert(node.getCount() == built, "a field update rebuilt the screen") +assert(tree.getCount() == built, "a field update rebuilt the screen") device.status = { state = "connected", ssid = "x", ip = "", rssi = -50 } assert(tick() == 1, "the signal strength repaints the icon and nothing else")