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.
This commit is contained in:
2026-08-05 10:26:43 -04:00
parent 823a664af7
commit 7bc9afae33
17 changed files with 170 additions and 190 deletions
+18 -18
View File
@@ -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
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+20 -16
View File
@@ -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
+15 -15
View File
@@ -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()
+14 -14
View File
@@ -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,
+3 -10
View File
@@ -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(),
-1
View File
@@ -44,7 +44,6 @@ private:
XPT2046_Touchscreen& touchPanel;
slate::Log logProvider;
slate::Settings settingsProvider;
slate::Sys sysProvider;
slate::Fs fsProvider;
slate::Gui guiProvider;
+5 -15
View File
@@ -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,
+23 -1
View File
@@ -2,6 +2,7 @@
#include <SD.h>
#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);
+2 -27
View File
@@ -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<int32_t>(::millis()); }
esp32lua::MemoryInfo Sys::memory() const {
+18 -12
View File
@@ -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 <SD.h>
#include <SPI.h>
#include <TFT_eSPI.h>
@@ -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);
}
+35 -44
View File
@@ -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 = {
+5 -5
View File
@@ -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.
+2 -2
View File
@@ -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")