From b3b5a9b2a3a0f8a8006fa2a2c14e59d10d6f2c6e Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Tue, 4 Aug 2026 20:44:13 -0400 Subject: [PATCH] feat(settings): persist the theme through the settings binding Keeps one writer for saved state: ui.setTheme() writes through settings.setTheme() and then reloads the palette C cannot see. --- AGENTS.md | 3 ++- README.md | 2 +- docs/lua-api-parity.md | 2 +- lib/esp32-lua-api | 2 +- src/host/providers.h | 2 ++ src/host/providers_sys.cpp | 12 ++++++++++++ src/settings.cpp | 4 ++-- src/settings.h | 6 +++--- test/fake_device.lua | 8 +++++++- 9 files changed, 31 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9391964..d79277a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,8 @@ initialize beside the Lua runtime; reserve `bt` and its full-ESP32 backend for f **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, and apps call that, never `settings.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 diff --git a/README.md b/README.md index 89bb542..c65751a 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ it calls `sys.setAppName("settings - wifi")`, which the status bar picks up on i | `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` | `getMillis()`, `delay(ms)`, `back()`, `launch(path, arg)`, `replace(path, arg)`, `getAppName()`, `setAppName(name)`, `getMemory()` -> `free,total,largest`, `isClockSynced()`, `setTickInterval(ms)` | -| `settings` | `getRotation()`, `setRotation(deg)`, `getTheme()`, `setTheme(name)`, `getTimezone()`, `setTimezone(tz)`, `setCalibration(x0,y0,x1,y1)` | +| `settings` | `getRotation()`, `setRotation(deg)`, `getTimezone()`, `setTimezone(tz)`, `getTheme()`, `setTheme(name)` (stores only; apps call `ui.setTheme`), `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) | diff --git a/docs/lua-api-parity.md b/docs/lua-api-parity.md index 1de809e..ff861f9 100644 --- a/docs/lua-api-parity.md +++ b/docs/lua-api-parity.md @@ -32,7 +32,7 @@ strength a status screen wants. Its `state` vocabulary is a subset: crosspoint r | 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 | `settings.getTheme/setTheme`, `/lib/theme.lua` | Colour panel. | +| Themes | none | `settings.getTheme/setTheme`, `ui.setTheme` | 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`. | diff --git a/lib/esp32-lua-api b/lib/esp32-lua-api index 291f0f2..e85adfa 160000 --- a/lib/esp32-lua-api +++ b/lib/esp32-lua-api @@ -1 +1 @@ -Subproject commit 291f0f20b59300c75465025a780c0af57d8b8543 +Subproject commit e85adfa757d940a7a58dc84ba99132fd8b2d71b4 diff --git a/src/host/providers.h b/src/host/providers.h index c917292..fd0c13e 100644 --- a/src/host/providers.h +++ b/src/host/providers.h @@ -28,6 +28,8 @@ public: 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; diff --git a/src/host/providers_sys.cpp b/src/host/providers_sys.cpp index a62f9d2..c2aa3cc 100644 --- a/src/host/providers_sys.cpp +++ b/src/host/providers_sys.cpp @@ -42,6 +42,18 @@ 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/settings.cpp b/src/settings.cpp index 156880e..7aefe23 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -69,8 +69,8 @@ bool Settings::load() { lua_pcall(L, 0, 1, 0) == LUA_OK && lua_istable(L, -1); if (ok) { rotation = fieldOr(L, "rotation", rotation); - theme = stringFieldOr(L, "theme", theme); timezone = stringFieldOr(L, "timezone", timezone); + theme = stringFieldOr(L, "theme", theme); lua_getfield(L, -1, "touch"); if (lua_istable(L, -1)) { touchX0 = fieldOr(L, "x0", touchX0); @@ -92,8 +92,8 @@ bool Settings::load() { bool Settings::save() const { String source = "return {\n rotation = " + String(rotation) + ",\n"; - source += " theme = " + luaString(theme) + ",\n"; source += " timezone = " + luaString(timezone) + ",\n"; + source += " theme = " + luaString(theme) + ",\n"; source += " touch = { x0 = " + String(touchX0) + ", y0 = " + String(touchY0) + ", x1 = " + String(touchX1) + ", y1 = " + String(touchY1) + " },\n"; diff --git a/src/settings.h b/src/settings.h index 1806215..b58bf1f 100644 --- a/src/settings.h +++ b/src/settings.h @@ -11,13 +11,13 @@ struct Settings { int16_t rotation = 0; String wifiSsid; String wifiPassword; - // Name of a palette in /lib/theme.lua; the colors themselves live on the - // card, so improving a theme never has to migrate saved settings. - String theme = "light"; // POSIX TZ string, applied with setenv("TZ")/tzset(). Storing the rule rather // than a zone name means newlib handles DST changeovers and an unlisted zone // still works. String timezone = "UTC0"; + // Name of a palette in ui.lua; the colors themselves live in Lua, so + // improving a theme never has to migrate saved settings. + String theme = "light"; uint8_t rotationIndex() const { return (rotation / 90) & 3; } bool setRotation(int16_t degrees); diff --git a/test/fake_device.lua b/test/fake_device.lua index ed8d646..8a8eb4b 100644 --- a/test/fake_device.lua +++ b/test/fake_device.lua @@ -220,6 +220,13 @@ function device.install() device.timezone = tz return saved() end, + getTheme = function() + return device.theme + end, + setTheme = function(name) + device.theme = name + return saved() + end, } input = { @@ -242,7 +249,6 @@ function device.install() fs = { MAX_READ_BYTES = 65536, - -- ui.lua persists the theme through fs, so the files table is real storage. readFile = function(path) return device.files[path] end,