From 45abf8a76788aed02423f4f6112ffba691ebda79 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Mon, 3 Aug 2026 18:12:33 -0400 Subject: [PATCH] refactor: run apps on the shared esp32-lua-api runtime Replaces the firmware's own lua_State, bindings, module loader, node tree and navigation history with lib/esp32-lua-api. What is left in src/host is the hardware behind the provider interfaces plus the chrome the firmware owns: the viewport, the status bar and touch polling. src/lua became src/host because src is on the include path, so a directory named lua shadowed the library's and #pragma once then silently skipped it. --- lib/esp32-lua-api | 2 +- src/host/lua_host.cpp | 269 +++++++++++++++++++ src/host/lua_host.h | 82 ++++++ src/host/providers.h | 166 ++++++++++++ src/host/providers_fs.cpp | 193 ++++++++++++++ src/host/providers_gui.cpp | 172 +++++++++++++ src/host/providers_net.cpp | 214 ++++++++++++++++ src/host/providers_sys.cpp | 112 ++++++++ src/lua/bindings.h | 33 --- src/lua/bindings/fs.cpp | 91 ------- src/lua/bindings/gui.cpp | 215 ---------------- src/lua/bindings/http.cpp | 322 ----------------------- src/lua/bindings/input.cpp | 54 ---- src/lua/bindings/node.cpp | 469 ---------------------------------- src/lua/bindings/settings.cpp | 112 -------- src/lua/bindings/sys.cpp | 151 ----------- src/lua/bindings/wifi.cpp | 142 ---------- src/lua/lua_app.cpp | 337 ------------------------ src/lua/lua_app.h | 142 ---------- src/lua/module_loader.cpp | 134 ---------- src/main.cpp | 107 +------- src/ui/layout.h | 421 ------------------------------ src/ui/paint.h | 184 ------------- 23 files changed, 1223 insertions(+), 2901 deletions(-) create mode 100644 src/host/lua_host.cpp create mode 100644 src/host/lua_host.h create mode 100644 src/host/providers.h create mode 100644 src/host/providers_fs.cpp create mode 100644 src/host/providers_gui.cpp create mode 100644 src/host/providers_net.cpp create mode 100644 src/host/providers_sys.cpp delete mode 100644 src/lua/bindings.h delete mode 100644 src/lua/bindings/fs.cpp delete mode 100644 src/lua/bindings/gui.cpp delete mode 100644 src/lua/bindings/http.cpp delete mode 100644 src/lua/bindings/input.cpp delete mode 100644 src/lua/bindings/node.cpp delete mode 100644 src/lua/bindings/settings.cpp delete mode 100644 src/lua/bindings/sys.cpp delete mode 100644 src/lua/bindings/wifi.cpp delete mode 100644 src/lua/lua_app.cpp delete mode 100644 src/lua/lua_app.h delete mode 100644 src/lua/module_loader.cpp delete mode 100644 src/ui/layout.h delete mode 100644 src/ui/paint.h diff --git a/lib/esp32-lua-api b/lib/esp32-lua-api index 95fa512..d53f9b7 160000 --- a/lib/esp32-lua-api +++ b/lib/esp32-lua-api @@ -1 +1 @@ -Subproject commit 95fa5120474b5fb6b1ece621cea6aad4f573e832 +Subproject commit d53f9b77cd520a6800633bc801a5479b8c8fd752 diff --git a/src/host/lua_host.cpp b/src/host/lua_host.cpp new file mode 100644 index 0000000..ec9677d --- /dev/null +++ b/src/host/lua_host.cpp @@ -0,0 +1,269 @@ +#include "lua_host.h" + +#include + +#include "../settings.h" + +extern "C" { +#include +#include +} + +namespace { + +esp32lua::Paths slatePaths() { + esp32lua::Paths paths; + paths.apps = "/.lua/apps"; + paths.data = "/.lua/data"; + paths.lib = "/.lua/lib"; + paths.home = "Home"; + return paths; +} + +// Reads an optional positive integer field off the bar module on the stack top. +lua_Integer barField(lua_State* state, const char* key, lua_Integer fallback) { + lua_getfield(state, -1, key); + const lua_Integer value = lua_isinteger(state, -1) ? lua_tointeger(state, -1) : fallback; + lua_pop(state, 1); + return value > 0 ? value : fallback; +} + +} // namespace + +// 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; + providers.http = &httpProvider; + providers.timer = &timerProvider; + 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(), slatePaths()) {} + +void LuaHost::mapTouch(const TS_Point& point, int16_t& x, int16_t& y) const { + const int16_t nx = constrain(map(point.y, settings.touchX0, settings.touchX1, 0, PANEL_W), 0, PANEL_W - 1); + const int16_t ny = constrain(map(point.x, settings.touchY0, settings.touchY1, 0, PANEL_H), 0, PANEL_H - 1); + switch (tft.getRotation() & 3) { + case 1: + x = ny; + y = PANEL_W - 1 - nx; + break; + case 2: + x = PANEL_W - 1 - nx; + y = PANEL_H - 1 - ny; + break; + case 3: + x = PANEL_H - 1 - ny; + y = nx; + break; + default: + x = nx; + y = ny; + break; + } + // App space starts below the bar, matching the viewport apps draw into. A tap on the bar + // itself lands at a negative y, which pollTouch() drops. + y -= 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 LuaHost::applyViewport() { + const int16_t inset = barInset(); + tft.resetViewport(); + if (inset > 0) tft.setViewport(0, inset, tft.width(), tft.height() - inset, true); +} + +void LuaHost::applyRotation() { + tft.setRotation(settings.rotationIndex()); + applyViewport(); + refreshStatusBar(); // every slot in the bar just moved +} + +void LuaHost::setFullscreen(bool on) { + fullscreen = on; + applyViewport(); + nextBarMs = 0; // leaving fullscreen left the bar's rows painted by the app +} + +bool LuaHost::begin() { return startApp("Home", ""); } + +bool LuaHost::startApp(const std::string& path, const std::string& arg) { + tft.setRotation(settings.rotationIndex()); // the previous app may have rotated the frame + fullscreen = false; + // Applied before the app loads, because init() measures the panel it was given. The height + // is last app's, which is the same module, and loadStatusBar() corrects it if that changes. + applyViewport(); + + Serial.printf("[lua] launching %s free=%u largest=%u\n", path.c_str(), ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + lastTouched = true; // the tap that launched this app may still be down + ignoreRelease = true; // and its release is not this app's gesture + backArmed = false; + + if (!runtime.startApp(path, arg)) { + fail(("could not start " + path).c_str()); + return false; + } + hasBack = runtime.canGoBack(); // the runtime keeps the history; the bar only offers the control + loadStatusBar(); + applyViewport(); + if (barInset() > 0 && !barBroken) drawStatusBar(); + + const uint32_t now = millis(); + nextDrawMs = now; + lastDrawMs = now; + return true; +} + +// The bar is a Lua module like any other, loaded per app because the state is too. +void LuaHost::loadStatusBar() { + lua_State* state = runtime.state(); + barBroken = false; + 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. + barBroken = true; + barHeight = 0; + lua_pop(state, 2); + return; + } + barHeight = barField(state, "height", DEFAULT_BAR_H); + barIntervalMs = std::max(DRAW_INTERVAL_MS, barField(state, "interval", BAR_INTERVAL_MS)); + lua_setglobal(state, "__statusbar"); + nextBarMs = 0; +} + +// Errors here disable the bar rather than killing the app: chrome that fails should not take +// the running program with it. +void LuaHost::drawStatusBar() { + lua_State* state = runtime.state(); + lua_getglobal(state, "__statusbar"); + lua_getfield(state, -1, "draw"); + lua_remove(state, -2); + lua_pushboolean(state, hasBack); + tft.resetViewport(); // the bar paints in panel coordinates, the app does not + if (lua_pcall(state, 1, 0, 0) != LUA_OK) { + Serial.printf("[statusbar] %s\n", luaL_tolstring(state, -1, nullptr)); + lua_pop(state, 2); + barBroken = true; + } + applyViewport(); +} + +// Deliberately not themed: this can fire when the settings naming the theme are themselves +// unreadable, so it stays high contrast. +void LuaHost::fail(const char* message) { + Serial.printf("[lua] %s\n", message ? message : "(unknown)"); + tft.resetViewport(); + tft.fillScreen(TFT_WHITE); + tft.setTextSize(2); + tft.setTextColor(TFT_RED, TFT_WHITE); + tft.drawString("Lua error:", 8, 8); + tft.setTextColor(TFT_BLACK, TFT_WHITE); + tft.drawString(message ? message : "(unknown)", 8, 28); +} + +void LuaHost::pollTouch() { + bool touched = touchPanel.touched(); + if (touched) mapTouch(touchPanel.getPoint(), lastX, lastY); + + // The bar is the host's: apps never see a touch in it, and its one control acts on release, + // so a press that slides off into the app cancels like any other button. + if (touched && lastY < 0) { + backArmed = backArmed || inBackButton(); + touched = false; + } else if (backArmed) { + backArmed = false; + if (inBackButton()) { + runtime.requestBack(); + return; + } + } + + if (touched && !lastTouched) { + movedX = lastX; + movedY = lastY; + runtime.callTouch(esp32lua::TouchPhase::Down, lastX, lastY); + } else if (touched && lastTouched) { + // The XPT2046 jitters a pixel or two under a still finger. Below this every poll would + // fire a move, and a painting app would draw a blur where nothing moved. + if (abs(lastX - movedX) + abs(lastY - movedY) >= MOVE_EPSILON_PX) { + movedX = lastX; + movedY = lastY; + runtime.callTouch(esp32lua::TouchPhase::Move, lastX, lastY); + } + } else if (!touched && lastTouched && ignoreRelease) { + ignoreRelease = false; + } else if (!touched && lastTouched) { + // The runtime fires the on_touch tap alias after the release. + runtime.callTouch(esp32lua::TouchPhase::Up, lastX, lastY); + } + lastTouched = touched; +} + +void LuaHost::pollTimers() { + std::vector due; + timerProvider.collectDue(millis(), due); + for (size_t at = 0; at < due.size(); at++) { + runtime.callTimer(due[at]); + if (runtime.hasPendingNavigation()) return; // the app is leaving; its other timers can wait + } +} + +void LuaHost::navigate() { + if (!runtime.applyPendingNavigation()) { + fail("app failed to start"); + return; + } + hasBack = runtime.canGoBack(); + loadStatusBar(); + applyViewport(); + if (barInset() > 0 && !barBroken) drawStatusBar(); + const uint32_t now = millis(); + nextDrawMs = now; + lastDrawMs = now; +} + +void LuaHost::loop() { + if (!runtime.hasApp()) return; + + pollTouch(); + if (!runtime.hasPendingNavigation()) pollTimers(); + + const uint32_t now = millis(); + if (!runtime.hasPendingNavigation() && now >= nextDrawMs) { + nextDrawMs = now + DRAW_INTERVAL_MS; + runtime.callDraw(static_cast(now - lastDrawMs)); + lastDrawMs = now; + } + + if (!runtime.hasPendingNavigation() && barInset() > 0 && !barBroken && now >= nextBarMs) { + nextBarMs = now + barIntervalMs; + drawStatusBar(); + } + + // Between batches, never inside one: swapping the lua_State mid-callback would free the VM + // that is still executing. + if (runtime.hasPendingNavigation()) navigate(); +} diff --git a/src/host/lua_host.h b/src/host/lua_host.h new file mode 100644 index 0000000..0e7550b --- /dev/null +++ b/src/host/lua_host.h @@ -0,0 +1,82 @@ +#pragma once + +// The firmware side of running a Lua app: the panel, the touch controller, the status bar +// and the frame pacing. The lua_State, the bindings, the node tree, app loading and +// navigation history all belong to esp32lua::Runtime. + +#include +#include +#include + +#include "providers.h" + +class LuaHost { + public: + LuaHost(TFT_eSPI& tft, XPT2046_Touchscreen& touch); + + // Starts the launcher, or reports the failure that stopped it. + bool begin(); + void loop(); + bool running() const { return runtime.hasApp(); } + + // Called by the providers, which is why they hold a reference to the host. + void applyViewport(); + void applyRotation(); + void setFullscreen(bool on); + void refreshStatusBar() { nextBarMs = 0; } + void mapTouch(const TS_Point& point, int16_t& x, int16_t& y) const; + + private: + static constexpr uint32_t DRAW_INTERVAL_MS = 33; + static constexpr uint32_t BAR_INTERVAL_MS = 1000; + static constexpr int16_t DEFAULT_BAR_H = 22; + static constexpr int16_t PANEL_W = 320; + static constexpr int16_t PANEL_H = 480; + static constexpr int16_t MOVE_EPSILON_PX = 2; + static constexpr uint32_t ERROR_HOLD_MS = 5000; + + int16_t barInset() const { return fullscreen ? 0 : barHeight; } + bool inBackButton() const { + return hasBack && lastY < 0 && lastY >= -barInset() && lastX >= 0 && lastX < barInset(); + } + + esp32lua::Providers wire(); + bool startApp(const std::string& path, const std::string& arg); + void navigate(); + void pollTouch(); + void pollTimers(); + void loadStatusBar(); + void drawStatusBar(); + void fail(const char* message); + + TFT_eSPI& tft; + XPT2046_Touchscreen& touchPanel; + + slate::Log logProvider; + slate::Settings settingsProvider; + slate::Sys sysProvider; + slate::Fs fsProvider; + slate::Gui guiProvider; + slate::Http httpProvider; + slate::Timer timerProvider; + slate::Wifi wifiProvider; + slate::Ble bleProvider; + slate::Touch touchProvider; + esp32lua::Runtime runtime; + + uint32_t nextDrawMs = 0; + uint32_t lastDrawMs = 0; + uint32_t nextBarMs = 0; + uint32_t barIntervalMs = BAR_INTERVAL_MS; + // Kept across apps: the bar is the same module for every one of them, and the viewport has + // to be right before init() measures the panel it was given. + int16_t barHeight = DEFAULT_BAR_H; + bool barBroken = false; + bool fullscreen = false; + bool hasBack = false; + bool backArmed = false; + bool lastTouched = false; + bool ignoreRelease = false; + int16_t lastX = 0, lastY = 0; + int16_t movedX = 0, movedY = 0; +}; diff --git a/src/host/providers.h b/src/host/providers.h new file mode 100644 index 0000000..b3e1633 --- /dev/null +++ b/src/host/providers.h @@ -0,0 +1,166 @@ +#pragma once + +// The firmware half of the shared Lua platform: hardware behind the interfaces in +// , and nothing else. Everything above these -- bindings, argument +// checking, the node tree, app loading, navigation -- lives in lib/esp32-lua-api. + +#include +#include +#include + +class LuaHost; + +namespace slate { + +class Log : public esp32lua::LogProvider { + 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; + + private: + LuaHost& host; +}; + +class Sys : public esp32lua::SysProvider { + public: + int32_t millis() const override; + esp32lua::MemoryInfo memory() const override; + bool isClockSynced() const override; +}; + +class Fs : public esp32lua::FsProvider { + public: + esp32lua::FileReader* openRead(const std::string& path) override; + bool exists(const std::string& path) const override; + esp32lua::Status fileSize(const std::string& path, int32_t& size) const override; + esp32lua::Status listDirs(const std::string& path, std::vector& names) const override; + esp32lua::Status listFiles(const std::string& path, std::vector& names) const override; + esp32lua::Status mkdir(const std::string& path) override; + esp32lua::Status readFile(const std::string& path, int32_t maxBytes, std::string& content) const override; + esp32lua::Status readLineAt(const std::string& path, int32_t offset, int32_t maxBytes, bool& found, + std::string& line, int32_t& nextOffset) const override; + esp32lua::Status remove(const std::string& path) override; + esp32lua::Status removeTree(const std::string& path) override; + esp32lua::Status rename(const std::string& source, const std::string& destination) override; + esp32lua::Status writeFile(const std::string& path, const std::string& content) override; +}; + +// Font roles map onto the one built-in font at whole-number scales, which is all TFT_eSPI +// offers here; STYLE_BOLD has no glyphs of its own and renders as normal. +class Gui : public esp32lua::GuiProvider { + public: + Gui(TFT_eSPI& tft, LuaHost& host) : tft(tft), host(host) {} + + esp32lua::FontIds fonts() const override; + int32_t width() const override; + int32_t height() const override; + int32_t rotation() const override; + void setRotation(int32_t degrees) 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, int32_t color) override; + void drawRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t color) override; + void drawLine(int32_t x1, int32_t y1, int32_t x2, int32_t y2, int32_t color, int32_t width) override; + void drawPixel(int32_t x, int32_t y, int32_t color) override; + void drawCircle(int32_t x, int32_t y, int32_t radius, int32_t color, int32_t width) override; + void fillCircle(int32_t x, int32_t y, int32_t radius, int32_t color, const int32_t* background) override; + void roundRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t radius, int32_t background, const int32_t* top, + const int32_t* bottom, const int32_t* border) override; + void fillPolygon(const int32_t* xs, const int32_t* ys, size_t count, int32_t color) override; + esp32lua::Status drawBmp(const std::string& path, const int32_t* x, const int32_t* y, const int32_t* maxWidth, + const int32_t* maxHeight) override; + int32_t textWidth(int32_t font, const std::string& text, int32_t style) const override; + int32_t fontHeight(int32_t font, int32_t style) const override; + void drawText(int32_t font, int32_t x, int32_t y, const std::string& text, int32_t color, int32_t style, + const int32_t* background) override; + void setFullscreen(bool on) override; + // The panel is live, so there is nothing pending to apply. + void commit() override {} + + private: + TFT_eSPI& tft; + LuaHost& host; +}; + +class Http : public esp32lua::HttpProvider { + public: + esp32lua::Status request(const std::string& method, const std::string& url, const std::string& body, + const std::vector& headers, int32_t maxBytes, + esp32lua::HttpResponse& response) override; + esp32lua::Status download(const std::string& url, const std::string& destination, + const esp32lua::HttpDownload& options, int32_t& bytesWritten) override; +}; + +class Wifi : public esp32lua::WifiProvider { + public: + esp32lua::Status scan(std::vector& networks) override; + esp32lua::Status connect(const std::string* ssid, const std::string* password) override; + esp32lua::WifiStatus status() const override; + void disconnect() override; + esp32lua::Status forget() override; +}; + +// This board has no radio for it. The namespace stays core so an app fails with a reason +// rather than a nil global it has to feature-test. +class Ble : public esp32lua::BleProvider { + public: + esp32lua::Status init(const std::string*) override { return unsupported(); } + void deinit() override {} + esp32lua::Status scan(int32_t, std::vector&) override { return unsupported(); } + esp32lua::Status connect(const std::string&) override { return unsupported(); } + void disconnect() override {} + bool isConnected() const override { return false; } + esp32lua::Status read(const std::string&, const std::string&, std::string&) override { return unsupported(); } + esp32lua::Status write(const std::string&, const std::string&, const std::string&) override { + return unsupported(); + } + esp32lua::Status startAdvertising(const std::string*) override { return unsupported(); } + void stopAdvertising() override {} + + private: + static esp32lua::Status unsupported() { return esp32lua::Status::failure("this device has no Bluetooth"); } +}; + +class Touch : public esp32lua::TouchProvider { + public: + Touch(XPT2046_Touchscreen& panel, LuaHost& host) : panel(panel), host(host) {} + bool touch(int32_t& x, int32_t& y) const override; + bool rawTouch(int32_t& x, int32_t& y) const override; + bool isTouched() const override; + esp32lua::Status setCalibration(int32_t x0, int32_t y0, int32_t x1, int32_t y1) override; + + private: + XPT2046_Touchscreen& panel; + LuaHost& host; +}; + +// Deadlines only: identity and the retained Lua callbacks belong to the runtime, which is +// called back through Runtime::callTimer() from the firmware loop. +class Timer : public esp32lua::TimerProvider { + public: + static constexpr int SLOTS = 8; + + esp32lua::Status schedule(esp32lua::TimerId id, int32_t intervalMs, bool repeating) override; + void cancel(esp32lua::TimerId id) override; + // Ids whose deadline has passed, rescheduling the repeating ones. + void collectDue(uint32_t nowMs, std::vector& due); + + private: + struct Slot { + esp32lua::TimerId id = 0; + uint32_t dueMs = 0; + int32_t intervalMs = 0; + bool repeating = false; + }; + Slot slots[SLOTS]; +}; + +} // namespace slate diff --git a/src/host/providers_fs.cpp b/src/host/providers_fs.cpp new file mode 100644 index 0000000..076aafa --- /dev/null +++ b/src/host/providers_fs.cpp @@ -0,0 +1,193 @@ +#include + +#include "providers.h" + +namespace slate { +namespace { + +using esp32lua::Status; + +// Streamed rather than slurped: holding a whole module as one buffer needs that many bytes +// contiguous, and once WiFi is up the largest free block is far below the free heap. +class SdReader : public esp32lua::FileReader { + public: + explicit SdReader(File file) : file(file) {} + ~SdReader() override { + if (file) file.close(); + } + int32_t read(char* out, int32_t maxBytes) override { + return file.read(reinterpret_cast(out), maxBytes); + } + + private: + File file; +}; + +Status listEntries(const std::string& path, bool directories, std::vector& names) { + File dir = SD.open(path.c_str()); + if (!dir || !dir.isDirectory()) { + if (dir) dir.close(); + return Status::failure("not a directory"); + } + for (File entry = dir.openNextFile(); entry; entry = dir.openNextFile()) { + if (entry.isDirectory() == directories && entry.name()[0] != '.') names.push_back(entry.name()); + entry.close(); + } + dir.close(); + std::sort(names.begin(), names.end()); + return Status::success(); +} + +bool removeRecursive(const String& path) { + File entry = SD.open(path); + if (!entry) return false; + if (!entry.isDirectory()) { + entry.close(); + return SD.remove(path); + } + for (File child = entry.openNextFile(); child; child = entry.openNextFile()) { + const String childPath = path + "/" + child.name(); + const bool isDirectory = child.isDirectory(); + child.close(); + if (!(isDirectory ? removeRecursive(childPath) : SD.remove(childPath))) { + entry.close(); + return false; + } + } + entry.close(); + return SD.rmdir(path); +} + +} // namespace + +esp32lua::FileReader* Fs::openRead(const std::string& path) { + // Probed first: a missed SD.open logs a VFS error, and the module searcher misses by design. + if (!SD.exists(path.c_str())) return nullptr; + File file = SD.open(path.c_str()); + if (!file || file.isDirectory()) { + if (file) file.close(); + return nullptr; + } + return new SdReader(file); +} + +bool Fs::exists(const std::string& path) const { return SD.exists(path.c_str()); } + +Status Fs::fileSize(const std::string& path, int32_t& size) const { + File file = SD.open(path.c_str()); + if (!file || file.isDirectory()) { + if (file) file.close(); + return Status::failure("not a file"); + } + size = static_cast(file.size()); + file.close(); + return Status::success(); +} + +Status Fs::listDirs(const std::string& path, std::vector& names) const { + return listEntries(path, true, names); +} + +Status Fs::listFiles(const std::string& path, std::vector& names) const { + return listEntries(path, false, names); +} + +Status Fs::mkdir(const std::string& path) { + if (SD.exists(path.c_str())) return Status::success(); + return SD.mkdir(path.c_str()) ? Status::success() : Status::failure("cannot create directory"); +} + +Status Fs::readFile(const std::string& path, int32_t maxBytes, std::string& content) const { + File file = SD.open(path.c_str()); + if (!file || file.isDirectory()) { + if (file) file.close(); + return Status::failure("not a file"); + } + const size_t size = file.size(); + if (size > static_cast(maxBytes)) { + file.close(); + return Status::failure("file exceeds maxBytes"); + } + content.resize(size); + const size_t read = size ? file.read(reinterpret_cast(&content[0]), size) : 0; + file.close(); + if (read != size) return Status::failure("short read"); + return Status::success(); +} + +Status Fs::readLineAt(const std::string& path, int32_t offset, int32_t maxBytes, bool& found, std::string& line, + int32_t& nextOffset) const { + File file = SD.open(path.c_str()); + if (!file || file.isDirectory()) { + if (file) file.close(); + return Status::failure("not a file"); + } + if (!file.seek(offset)) { + file.close(); + return Status::failure("cannot seek"); + } + if (offset > 0) { + // A mid-line offset advances to the next line, so a caller can resume from any byte. + while (file.available() && file.peek() != '\n') file.read(); + if (file.available()) file.read(); + } + if (!file.available()) { + file.close(); + found = false; + return Status::success(); + } + + line.clear(); + while (file.available()) { + const int c = file.read(); + if (c == '\n') break; + if (c != '\r') line.push_back(static_cast(c)); + if (static_cast(line.size()) > maxBytes) { + file.close(); + return Status::failure("line exceeds maxBytes"); + } + } + nextOffset = static_cast(file.position()); + file.close(); + found = true; + return Status::success(); +} + +Status Fs::remove(const std::string& path) { + return SD.remove(path.c_str()) ? Status::success() : Status::failure("cannot remove"); +} + +Status Fs::removeTree(const std::string& path) { + return removeRecursive(String(path.c_str())) ? Status::success() : Status::failure("cannot remove tree"); +} + +Status Fs::rename(const std::string& source, const std::string& destination) { + if (SD.exists(destination.c_str())) return Status::failure("destination exists"); + return SD.rename(source.c_str(), destination.c_str()) ? Status::success() : Status::failure("cannot rename"); +} + +// Written to a temporary first: a half-written file that replaced a good one is worse than a +// failed write that left the old contents alone. +Status Fs::writeFile(const std::string& path, const std::string& content) { + const String target(path.c_str()); + const String temporary = target + ".tmp"; + SD.remove(temporary); + + File file = SD.open(temporary, FILE_WRITE); + if (!file) return Status::failure("cannot open for writing"); + const size_t written = content.empty() ? 0 : file.write(reinterpret_cast(content.data()), + content.size()); + file.close(); + if (written != content.size()) { + SD.remove(temporary); + return Status::failure("short write"); + } + SD.remove(target); + if (!SD.rename(temporary, target)) { + SD.remove(temporary); + return Status::failure("cannot replace destination"); + } + return Status::success(); +} + +} // namespace slate diff --git a/src/host/providers_gui.cpp b/src/host/providers_gui.cpp new file mode 100644 index 0000000..8cb4f2f --- /dev/null +++ b/src/host/providers_gui.cpp @@ -0,0 +1,172 @@ +#include +#include + +#include "../gfx/round_rect.h" +#include "lua_host.h" +#include "providers.h" + +namespace slate { +namespace { + +constexpr int MAX_SPAN = 480; // longest panel edge, so one row buffer covers any shape + +// One primitive draws the whole surface: fill (solid or vertical gradient) and border derive +// from the same distance field, so they cannot disagree at the corners the way two separate +// rounded-rect algorithms did. The panel has no alpha, so edge pixels blend against `surface`. +void paintRoundRect(TFT_eSPI& tft, int x, int y, int w, int h, float radius, uint16_t surface, bool hasFill, + uint16_t top, uint16_t bottom, bool hasBorder, uint16_t border) { + if (w <= 0 || h <= 0 || w > MAX_SPAN) return; + float halfWidth = w * 0.5f, halfHeight = h * 0.5f; + if (radius < 0.0f) radius = 0.0f; + const float limit = (w < h ? w : h) / 2.0f; + if (radius > limit) radius = limit; + + static uint16_t span[MAX_SPAN]; + + // pushImage sends the buffer verbatim, but the panel wants each colour big-endian. + const bool previousSwap = tft.getSwapBytes(); + tft.setSwapBytes(true); + for (int row = 0; row < h; row++) { + const uint16_t fill = hasFill ? gfx::lerp565(top, bottom, row, h - 1) : 0; + const float py = row + 0.5f - halfHeight; + for (int column = 0; column < w; column++) { + const float distance = gfx::roundRectDistance(column + 0.5f - halfWidth, py, halfWidth, halfHeight, radius); + const float outer = gfx::coverage(distance); + // The border is the ring between the shape and the same shape inset by its width. + const float inner = hasBorder ? gfx::coverage(distance + 1.0f) : outer; + uint16_t pixel = surface; + if (hasFill) pixel = gfx::blend565(pixel, fill, inner); + if (hasBorder) pixel = gfx::blend565(pixel, border, outer - inner); + span[column] = pixel; + } + tft.pushImage(x, y + row, w, 1, span); + } + tft.setSwapBytes(previousSwap); +} + +// Font roles are whole-number scales of the one built-in font, which is what this driver has. +uint8_t scaleFor(int32_t font) { return static_cast(font < 1 ? 1 : (font > 8 ? 8 : font)); } + +} // namespace + +esp32lua::FontIds Gui::fonts() const { + esp32lua::FontIds ids; + ids.small = 1; + ids.ui = 2; + ids.body = 2; + ids.large = 3; + ids.styleNormal = 0; + ids.styleBold = 0; // no bold glyphs, so bold text is normal text rather than a different metric + return ids; +} + +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); + host.applyViewport(); + host.refreshStatusBar(); // every slot in the bar just moved +} + +int32_t Gui::color(int32_t r, int32_t g, int32_t b) const { return tft.color565(r, g, b); } +void Gui::clear(int32_t color) { tft.fillScreen(color); } +void Gui::fillRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t color) { tft.fillRect(x, y, w, h, color); } +void Gui::drawRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t color) { tft.drawRect(x, y, w, h, color); } +void Gui::drawPixel(int32_t x, int32_t y, int32_t color) { tft.drawPixel(x, y, color); } + +void Gui::drawLine(int32_t x1, int32_t y1, int32_t x2, int32_t y2, int32_t color, int32_t width) { + if (width <= 1) { + tft.drawLine(x1, y1, x2, y2, color); + return; + } + tft.drawWideLine(x1, y1, x2, y2, static_cast(width), color, color); +} + +void Gui::drawCircle(int32_t x, int32_t y, int32_t radius, int32_t color, int32_t width) { + for (int32_t ring = 0; ring < (width < 1 ? 1 : width); ring++) { + const int32_t r = radius - ring; + if (r > 0) tft.drawCircle(x, y, r, color); + } +} + +void Gui::fillCircle(int32_t x, int32_t y, int32_t radius, int32_t color, const int32_t* background) { + tft.fillSmoothCircle(x, y, radius, color, background ? *background : TFT_WHITE); +} + +void Gui::roundRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t radius, int32_t background, + const int32_t* top, const int32_t* bottom, const int32_t* border) { + const uint16_t fillTop = top ? static_cast(*top) : 0; + const uint16_t fillBottom = bottom ? static_cast(*bottom) : fillTop; + paintRoundRect(tft, x, y, w, h, static_cast(radius), static_cast(background), top != nullptr, + fillTop, fillBottom, border != nullptr, border ? static_cast(*border) : 0); +} + +// Scanline fill: TFT_eSPI only offers triangles, and fanning a concave shape into triangles +// paints outside it. +void Gui::fillPolygon(const int32_t* xs, const int32_t* ys, size_t count, int32_t color) { + if (count < 3) return; + int32_t top = ys[0], bottom = ys[0]; + for (size_t at = 1; at < count; at++) { + if (ys[at] < top) top = ys[at]; + if (ys[at] > bottom) bottom = ys[at]; + } + + for (int32_t y = top; y <= bottom; y++) { + int32_t crossings[16]; + size_t found = 0; + for (size_t at = 0; at < count && found < 16; at++) { + const size_t next = (at + 1) % count; + const int32_t y0 = ys[at], y1 = ys[next]; + if ((y0 <= y && y1 > y) || (y1 <= y && y0 > y)) { + crossings[found++] = xs[at] + (y - y0) * (xs[next] - xs[at]) / (y1 - y0); + } + } + for (size_t a = 0; a + 1 < found; a++) { + for (size_t b = a + 1; b < found; b++) { + if (crossings[b] < crossings[a]) { + const int32_t swap = crossings[a]; + crossings[a] = crossings[b]; + crossings[b] = swap; + } + } + } + for (size_t at = 0; at + 1 < found; at += 2) { + tft.drawFastHLine(crossings[at], y, crossings[at + 1] - crossings[at] + 1, color); + } + } +} + +// ponytail: no BMP decoder on this panel yet. Apps that need artwork draw it; wire a decoder +// in when one actually ships a bitmap. +esp32lua::Status Gui::drawBmp(const std::string& path, const int32_t*, const int32_t*, const int32_t*, + const int32_t*) { + if (!SD.exists(path.c_str())) return esp32lua::Status::failure("no such file"); + return esp32lua::Status::failure("BMP drawing is not implemented on this device"); +} + +int32_t Gui::textWidth(int32_t font, const std::string& text, int32_t) const { + tft.setTextSize(scaleFor(font)); + return tft.textWidth(text.c_str()); +} + +int32_t Gui::fontHeight(int32_t font, int32_t) const { + tft.setTextSize(scaleFor(font)); + return tft.fontHeight(); +} + +void Gui::drawText(int32_t font, int32_t x, int32_t y, const std::string& text, int32_t color, int32_t, + const int32_t* background) { + tft.setTextSize(scaleFor(font)); + if (background) { + tft.setTextColor(color, *background); + } else { + tft.setTextColor(color); + } + tft.drawString(text.c_str(), x, y); +} + +void Gui::setFullscreen(bool on) { host.setFullscreen(on); } + +} // namespace slate diff --git a/src/host/providers_net.cpp b/src/host/providers_net.cpp new file mode 100644 index 0000000..1ff9cef --- /dev/null +++ b/src/host/providers_net.cpp @@ -0,0 +1,214 @@ +#include +#include +#include +#include +#include +#include + +#include "../settings.h" +#include "providers.h" + +extern const uint8_t rootca_crt_bundle_start[] asm("_binary_x509_crt_bundle_start"); + +namespace slate { +namespace { + +using esp32lua::Status; + +constexpr uint32_t TIMEOUT_MS = 60000; +constexpr int REDIRECT_LIMIT = 5; +constexpr size_t DOWNLOAD_CHUNK = 2048; + +bool isHttps(const std::string& url) { return url.rfind("https://", 0) == 0; } + +// One session owns both clients: the secure one carries the CA bundle, and HTTPClient keeps a +// reference to whichever it was handed, so neither may outlive the request. +struct Session { + WiFiClient plain; + WiFiClientSecure secure; + HTTPClient http; + + bool begin(const std::string& url) { + if (isHttps(url)) { + secure.setCACertBundle(rootca_crt_bundle_start); + secure.setTimeout(TIMEOUT_MS / 1000); + if (!http.begin(secure, url.c_str())) return false; + } else if (!http.begin(plain, url.c_str())) { + return false; + } + http.setTimeout(TIMEOUT_MS); + http.setConnectTimeout(TIMEOUT_MS); + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.setRedirectLimit(REDIRECT_LIMIT); + http.setUserAgent("slate32"); + return true; + } +}; + +bool hashFile(const char* path, uint8_t digest[32]) { + File file = SD.open(path); + if (!file) return false; + mbedtls_sha256_context sha; + mbedtls_sha256_init(&sha); + mbedtls_sha256_starts_ret(&sha, 0); + static uint8_t chunk[DOWNLOAD_CHUNK]; + while (file.available()) { + const int read = file.read(chunk, sizeof(chunk)); + if (read <= 0) break; + mbedtls_sha256_update_ret(&sha, chunk, read); + } + file.close(); + mbedtls_sha256_finish_ret(&sha, digest); + mbedtls_sha256_free(&sha); + return true; +} + +std::string hex(const uint8_t* digest, size_t length) { + std::string out; + for (size_t at = 0; at < length; at++) { + char pair[3]; + snprintf(pair, sizeof(pair), "%02x", digest[at]); + out += pair; + } + return out; +} + +bool equalsIgnoreCase(const std::string& a, const std::string& b) { + if (a.size() != b.size()) return false; + for (size_t at = 0; at < a.size(); at++) { + if (tolower(static_cast(a[at])) != tolower(static_cast(b[at]))) return false; + } + return true; +} + +} // namespace + +Status Http::request(const std::string& method, const std::string& url, const std::string& body, + const std::vector& headers, int32_t maxBytes, + esp32lua::HttpResponse& response) { + Session session; + if (!session.begin(url)) return Status::failure("cannot reach " + url); + for (size_t at = 0; at < headers.size(); at++) { + session.http.addHeader(headers[at].name.c_str(), headers[at].value.c_str()); + } + + const int status = session.http.sendRequest(method.c_str(), reinterpret_cast(const_cast( + body.data())), + body.size()); + if (status <= 0) return Status::failure(HTTPClient::errorToString(status).c_str()); + + // Refused on the declared size where there is one, so an oversized body is never allocated. + const int declared = session.http.getSize(); + if (declared > maxBytes) return Status::failure("response exceeds maxBytes"); + + const String payload = session.http.getString(); + if (static_cast(payload.length()) > maxBytes) return Status::failure("response exceeds maxBytes"); + response.status = status; + response.body.assign(payload.c_str(), payload.length()); + return Status::success(); +} + +Status Http::download(const std::string& url, const std::string& destination, const esp32lua::HttpDownload& options, + int32_t& bytesWritten) { + if (!isHttps(url)) return Status::failure("download requires an HTTPS URL"); + if (SD.exists(destination.c_str())) return Status::failure("destination exists"); + + Session session; + if (!session.begin(url)) return Status::failure("cannot reach " + url); + if (session.http.GET() != HTTP_CODE_OK) return Status::failure("download failed"); + const int declared = session.http.getSize(); + if (declared > 0 && declared > options.maxBytes) return Status::failure("download exceeds maxBytes"); + + File out = SD.open(destination.c_str(), FILE_WRITE); + if (!out) return Status::failure("cannot open destination"); + const int result = session.http.writeToStream(&out); + out.close(); + + const int32_t written = result > 0 ? result : 0; + const char* failure = nullptr; + if (result < 0) { + failure = "download failed"; + } else if (written == 0) { + failure = "download aborted"; + } else if (written > options.maxBytes) { + failure = "download exceeds maxBytes"; + } else if (options.expectedSize && written != options.expectedSize) { + failure = "size did not match expectedSize"; + } + if (!failure && !options.sha256.empty()) { + uint8_t digest[32]; + if (!hashFile(destination.c_str(), digest) || !equalsIgnoreCase(options.sha256, hex(digest, sizeof(digest)))) { + failure = "SHA-256 did not match"; + } + } + // Partial or unverified output never survives, so a retry starts clean. + if (failure) { + SD.remove(destination.c_str()); + return Status::failure(failure); + } + bytesWritten = written; + return Status::success(); +} + +Status Wifi::scan(std::vector& networks) { + const int found = WiFi.scanNetworks(); + if (found < 0) return Status::failure("scan failed"); + for (int at = 0; at < found; at++) { + esp32lua::WifiNetwork network; + network.ssid = WiFi.SSID(at).c_str(); + network.rssi = WiFi.RSSI(at); + network.secure = WiFi.encryptionType(at) != WIFI_AUTH_OPEN; + if (!network.ssid.empty()) networks.push_back(network); + } + WiFi.scanDelete(); + return Status::success(); +} + +Status Wifi::connect(const std::string* ssid, const std::string* password) { + if (ssid) { + if (ssid->empty()) return Status::failure("ssid is empty"); + settings.wifiSsid = ssid->c_str(); + settings.wifiPassword = password ? password->c_str() : ""; + if (!settings.save()) return Status::failure("cannot save credentials"); + } + if (settings.wifiSsid.isEmpty()) return Status::failure("no saved network"); + + WiFi.mode(WIFI_STA); + WiFi.begin(settings.wifiSsid.c_str(), settings.wifiPassword.c_str()); + return Status::success(); +} + +esp32lua::WifiStatus Wifi::status() const { + esp32lua::WifiStatus status; + status.ssid = WiFi.SSID().c_str(); + status.ip = WiFi.localIP().toString().c_str(); + status.rssi = WiFi.RSSI(); + switch (WiFi.status()) { + case WL_CONNECTED: + status.state = "connected"; + break; + case WL_IDLE_STATUS: + case WL_DISCONNECTED: + status.state = settings.wifiSsid.isEmpty() ? "disconnected" : "connecting"; + break; + case WL_NO_SSID_AVAIL: + status.state = "not_found"; + break; + default: + status.state = "failed"; + break; + } + if (status.state != "connected") status.ip = "0.0.0.0"; + return status; +} + +void Wifi::disconnect() { WiFi.disconnect(); } + +Status Wifi::forget() { + WiFi.disconnect(true); + settings.wifiSsid = ""; + settings.wifiPassword = ""; + return settings.save() ? Status::success() : Status::failure("cannot save settings"); +} + +} // namespace slate diff --git a/src/host/providers_sys.cpp b/src/host/providers_sys.cpp new file mode 100644 index 0000000..d858298 --- /dev/null +++ b/src/host/providers_sys.cpp @@ -0,0 +1,112 @@ +#include +#include + +#include "../net.h" +#include "../settings.h" +#include "lua_host.h" +#include "providers.h" + +namespace slate { + +using esp32lua::Status; + +void Log::write(esp32lua::LogLevel level, const std::string& message) { + const char* tag = level == esp32lua::LogLevel::Error ? "error" + : (level == esp32lua::LogLevel::Info ? "info" : "debug"); + Serial.printf("[lua] %s: %s\n", tag, message.c_str()); +} + +int32_t Settings::rotation() const { return settings.rotation; } + +// 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) { + if (timezone.empty() || timezone.size() > 48) return Status::failure("timezone must be 1 to 48 characters"); + settings.timezone = timezone.c_str(); + net::applyTimezone(); + return settings.save() ? Status::success() : Status::failure("cannot save settings"); +} + +int32_t Sys::millis() const { return static_cast(::millis()); } + +esp32lua::MemoryInfo Sys::memory() const { + esp32lua::MemoryInfo info; + info.freeBytes = static_cast(ESP.getFreeHeap()); + info.totalBytes = static_cast(ESP.getHeapSize()); + info.largestFreeBlock = static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); + return info; +} + +bool Sys::isClockSynced() const { return net::clockSynced(); } + +bool Touch::touch(int32_t& x, int32_t& y) const { + if (!panel.touched()) return false; + int16_t mappedX = 0, mappedY = 0; + host.mapTouch(panel.getPoint(), mappedX, mappedY); + x = mappedX; + y = mappedY; + return true; +} + +bool Touch::rawTouch(int32_t& x, int32_t& y) const { + if (!panel.touched()) return false; + const TS_Point point = panel.getPoint(); + // The controller's axes are swapped relative to the panel, which calibration undoes. + x = point.y; + y = point.x; + return true; +} + +bool Touch::isTouched() const { return panel.touched(); } + +Status Touch::setCalibration(int32_t x0, int32_t y0, int32_t x1, int32_t y1) { + settings.touchX0 = x0; + settings.touchY0 = y0; + settings.touchX1 = x1; + settings.touchY1 = y1; + return settings.save() ? Status::success() : Status::failure("cannot save settings"); +} + +// A fixed slot table rather than a heap list: the whole point of the arena elsewhere is that +// a Lua app cannot fragment the heap, and timers are created from Lua. +Status Timer::schedule(esp32lua::TimerId id, int32_t intervalMs, bool repeating) { + for (int at = 0; at < SLOTS; at++) { + if (slots[at].id != 0) continue; + slots[at].id = id; + slots[at].dueMs = ::millis() + intervalMs; + slots[at].intervalMs = intervalMs; + slots[at].repeating = repeating; + return Status::success(); + } + return Status::failure("no timer slot available"); +} + +void Timer::cancel(esp32lua::TimerId id) { + for (int at = 0; at < SLOTS; at++) { + if (slots[at].id == id) slots[at] = Slot(); + } +} + +void Timer::collectDue(uint32_t nowMs, std::vector& due) { + for (int at = 0; at < SLOTS; at++) { + if (slots[at].id == 0) continue; + // Signed comparison, so a deadline still pending across the millis() wrap is not "due". + if (static_cast(nowMs - slots[at].dueMs) < 0) continue; + due.push_back(slots[at].id); + if (slots[at].repeating) { + slots[at].dueMs = nowMs + slots[at].intervalMs; + } else { + slots[at] = Slot(); + } + } +} + +} // namespace slate diff --git a/src/lua/bindings.h b/src/lua/bindings.h deleted file mode 100644 index bf6b655..0000000 --- a/src/lua/bindings.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once - -// Shared internals for the binding translation units. Each `gui`, `sys`, ... table -// lives in its own file so that adding a binding touches one small file rather than -// the runtime everything else also edits. - -extern "C" { -#include -#include -} - -#include - -class LuaApp; - -// The owning app, recovered from the state itself rather than a file-static, so a -// second lua_State (a coroutine, or a future second app) cannot reach the wrong one. -LuaApp* app(lua_State* L); -void bindApp(lua_State* L, LuaApp* owner); - -void registerGui(lua_State* L); -void registerNode(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); -void registerHttp(lua_State* L); - -// Reads a script through the SD mount; Lua's stock loaders use stdio, which cannot -// see it. Shared because both `require` and the app launcher need it. -bool readScript(const char* path, String& out); -int loadScript(lua_State* L, const char* path); diff --git a/src/lua/bindings/fs.cpp b/src/lua/bindings/fs.cpp deleted file mode 100644 index 6ac754f..0000000 --- a/src/lua/bindings/fs.cpp +++ /dev/null @@ -1,91 +0,0 @@ -// SD card access. Paths are absolute and rooted at the card. - -#include - -#include "../bindings.h" -#include "../lua_app.h" - -static int l_fs_readFile(lua_State* L) { - File file = SD.open(luaL_checkstring(L, 1)); - if (!file || file.isDirectory()) { - lua_pushnil(L); - return 1; - } - // ponytail: whole-file reads only, capped at 64KB - String content = file.readString(); - file.close(); - if (content.length() > LuaApp::READ_CAP) content.remove(LuaApp::READ_CAP); - lua_pushlstring(L, content.c_str(), content.length()); - return 1; -} - -static int l_fs_writeFile(lua_State* L) { - File file = SD.open(luaL_checkstring(L, 1), FILE_WRITE); - if (!file) { - lua_pushboolean(L, false); - return 1; - } - size_t length; - const char* data = luaL_checklstring(L, 2, &length); - bool ok = file.write(reinterpret_cast(data), length) == length; - file.close(); - lua_pushboolean(L, ok); - return 1; -} - -static int l_fs_exists(lua_State* L) { - lua_pushboolean(L, SD.exists(luaL_checkstring(L, 1))); - return 1; -} - -// Directories and files list through the same walk, differing only in what they keep. -static int listEntries(lua_State* L, bool directories) { - File dir = SD.open(luaL_checkstring(L, 1)); - lua_newtable(L); - if (!dir || !dir.isDirectory()) { - if (dir) dir.close(); - return 1; - } - int index = 1; - for (File entry = dir.openNextFile(); entry; entry = dir.openNextFile()) { - if (entry.isDirectory() == directories && entry.name()[0] != '.') { - lua_pushstring(L, entry.name()); - lua_rawseti(L, -2, index++); - } - entry.close(); - } - dir.close(); - return 1; -} - -static int l_fs_listDirs(lua_State* L) { return listEntries(L, true); } - -static int l_fs_listFiles(lua_State* L) { return listEntries(L, false); } - -void registerFs(lua_State* L) { - static const luaL_Reg lib[] = { - // --- Reads a whole file from the SD card. - // @param path string Absolute path. - // @return string|nil Contents truncated to 65536 bytes, or nil when missing. - {"readFile", l_fs_readFile}, - // --- Writes a whole file to the SD card, replacing it if it exists. - // @param path string Absolute path. - // @param content string - // @return boolean - {"writeFile", l_fs_writeFile}, - // --- Whether a path exists. - // @param path string Absolute path. - // @return boolean - {"exists", l_fs_exists}, - // --- Names of the files in a directory, excluding dotfiles. - // @param path string Absolute path. - // @return string[] - {"listFiles", l_fs_listFiles}, - // --- Names of the subdirectories in a directory, excluding dotfiles. - // @param path string Absolute path. - // @return string[] - {"listDirs", l_fs_listDirs}, - {nullptr, nullptr}}; - luaL_newlib(L, lib); - lua_setglobal(L, "fs"); -} diff --git a/src/lua/bindings/gui.cpp b/src/lua/bindings/gui.cpp deleted file mode 100644 index aaa63b7..0000000 --- a/src/lua/bindings/gui.cpp +++ /dev/null @@ -1,215 +0,0 @@ -// Drawing primitives. Colours are RGB565 integers throughout. - -#include - -#include "../../ui/paint.h" -#include "../bindings.h" -#include "../lua_app.h" - -static int l_gui_width(lua_State* L) { - lua_pushinteger(L, app(L)->tft.width()); - return 1; -} - -static int l_gui_height(lua_State* L) { - lua_pushinteger(L, app(L)->tft.height()); - return 1; -} - -static int l_gui_clear(lua_State* L) { - app(L)->tft.fillScreen(luaL_optinteger(L, 1, TFT_WHITE)); - return 0; -} - -static int l_gui_fillRect(lua_State* L) { - app(L)->tft.fillRect(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3), - luaL_checkinteger(L, 4), luaL_checkinteger(L, 5)); - return 0; -} - -static int l_gui_drawRect(lua_State* L) { - app(L)->tft.drawRect(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3), - luaL_checkinteger(L, 4), luaL_checkinteger(L, 5)); - return 0; -} - -// Anti-aliased, so it needs the surface behind it to blend the rim against. -static int l_gui_fillCircle(lua_State* L) { - app(L)->tft.fillSmoothCircle(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), - luaL_checkinteger(L, 3), luaL_checkinteger(L, 4), - luaL_optinteger(L, 5, TFT_WHITE)); - return 0; -} - -static int l_gui_drawLine(lua_State* L) { - app(L)->tft.drawLine(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3), - luaL_checkinteger(L, 4), luaL_checkinteger(L, 5)); - return 0; -} - -// The same primitive the node tree paints buttons and cards with, so a Lua custom -// painter and a built-in widget cannot render a different rounded rect. -static int l_gui_roundRect(lua_State* L) { - bool hasFill = !lua_isnoneornil(L, 7); - bool hasBorder = !lua_isnoneornil(L, 9); - uint16_t top = hasFill ? luaL_checkinteger(L, 7) : 0; - uint16_t bottom = lua_isnoneornil(L, 8) ? top : luaL_checkinteger(L, 8); - ui::drawRoundRect(app(L)->tft, luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), - luaL_checkinteger(L, 3), luaL_checkinteger(L, 4), luaL_checkinteger(L, 5), - luaL_checkinteger(L, 6), hasFill, top, bottom, hasBorder, - hasBorder ? luaL_checkinteger(L, 9) : 0); - return 0; -} - -static int l_gui_fontHeight(lua_State* L) { - lua_pushinteger(L, app(L)->tft.fontHeight()); - return 1; -} - -// Integer multiples of the one built-in font, which is all the driver offers. fontHeight() -// and textWidth() follow it, so layout keeps working at any size. -static int l_gui_setTextSize(lua_State* L) { - app(L)->tft.setTextSize(constrain(luaL_checkinteger(L, 1), 1, 8)); - return 0; -} - -static int l_gui_textWidth(lua_State* L) { - lua_pushinteger(L, app(L)->tft.textWidth(luaL_checkstring(L, 1))); - return 1; -} - -static int l_gui_drawText(lua_State* L) { - const char* text = luaL_checkstring(L, 1); - int x = luaL_checkinteger(L, 2); - int y = luaL_checkinteger(L, 3); - // No background colour means transparent glyphs. An opaque fill is one flat colour, so - // text over a gradient shows its own patch; the caller that repaints the surface behind - // the text first does not need the fill at all. - if (lua_isnoneornil(L, 5)) { - app(L)->tft.setTextColor(luaL_optinteger(L, 4, TFT_BLACK)); - } else { - app(L)->tft.setTextColor(luaL_optinteger(L, 4, TFT_BLACK), lua_tointeger(L, 5)); - } - app(L)->tft.drawString(text, x, y); - return 0; -} - -// 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) { - 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(); - app(L)->refreshStatusBar(); // every slot in the bar just moved - 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) { - app(L)->setFullscreen(lua_toboolean(L, 1)); - return 0; -} - -// ponytail: RGB565 hex constants (0xF800 etc.) live in Lua; apps do gui.color(255,0,0) here. -static int l_gui_color(lua_State* L) { - lua_pushinteger(L, app(L)->tft.color565(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), - luaL_checkinteger(L, 3))); - return 1; -} - -void registerGui(lua_State* L) { - static const luaL_Reg lib[] = { - // --- Panel width in pixels, for the current rotation. - // @return integer - {"getWidth", l_gui_width}, - // --- Hands the app the whole panel, hiding the status bar, until it is turned off. - // @param on boolean - {"setFullscreen", l_gui_fullscreen}, - // --- Panel height in pixels, for the current rotation. - // @return integer - {"getHeight", l_gui_height}, - // --- Fills the whole panel with one color. - // @param color integer|nil Defaults to white. - {"clear", l_gui_clear}, - // --- Fills a rectangle. - // @param x integer - // @param y integer - // @param w integer - // @param h integer - // @param color integer - {"fillRect", l_gui_fillRect}, - // --- Strokes a one pixel rectangle outline. - // @param x integer - // @param y integer - // @param w integer - // @param h integer - // @param color integer - {"drawRect", l_gui_drawRect}, - // --- Fills an anti-aliased circle, blending its rim against the surface behind it. - // @param x integer Center. - // @param y integer Center. - // @param radius integer - // @param color integer - // @param bg integer|nil Surface color to blend against, defaults to white. - {"fillCircle", l_gui_fillCircle}, - // --- Strokes a line. - // @param x1 integer - // @param y1 integer - // @param x2 integer - // @param y2 integer - // @param color integer - {"drawLine", l_gui_drawLine}, - // --- Draws text with an opaque background behind its glyphs. - // @param text string - // @param x integer - // @param y integer - // @param color integer|nil Defaults to black. - // @param bg integer|nil Omitted draws transparent glyphs, over whatever is behind them. - {"drawText", l_gui_drawText}, - // --- Draws a rounded rectangle: fill, vertical gradient and border from one - // --- distance field, so the edges cannot disagree. - // @param x integer - // @param y integer - // @param w integer - // @param h integer - // @param radius integer Clamped to half the shorter side. - // @param bg integer Surface color the anti-aliased edge blends against. - // @param top integer|nil Fill color, or the top of the gradient. - // @param bottom integer|nil Bottom of the gradient, defaults to top. - // @param border integer|nil Border color; omitted draws no border. - {"roundRect", l_gui_roundRect}, - // --- Scales the built-in font by a whole number, for this app only. - // @param size integer 1 to 8. - {"setTextSize", l_gui_setTextSize}, - // --- Height of the current font in pixels, at the current text size. - // @return integer - {"getFontHeight", l_gui_fontHeight}, - // --- Width the given text would occupy in pixels. - // @param text string - // @return integer - {"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 - // @param b integer - // @return integer - {"color", l_gui_color}, - {nullptr, nullptr}}; - luaL_newlib(L, lib); - lua_setglobal(L, "gui"); -} diff --git a/src/lua/bindings/http.cpp b/src/lua/bindings/http.cpp deleted file mode 100644 index e41b3ed..0000000 --- a/src/lua/bindings/http.cpp +++ /dev/null @@ -1,322 +0,0 @@ -// HTTP client. Certificates are verified against the root bundle already embedded in -// the framework, and nothing here lets a script turn that off: the signatures have no -// options table to hang it on, and the one caller that would want it most -- a firmware -// update -- is the one that can least afford an unauthenticated peer. - -#include -#include -#include -#include -#include - -#include "../bindings.h" -#include "../lua_app.h" - -extern const uint8_t rootca_crt_bundle_start[] asm("_binary_x509_crt_bundle_start"); - -namespace { - -constexpr size_t MAX_RESPONSE = 50000; -constexpr uint32_t TIMEOUT_MS = 60000; -constexpr int REDIRECT_LIMIT = 5; -constexpr size_t DOWNLOAD_CHUNK = 2048; -constexpr uint32_t MAX_DOWNLOAD_BYTES = 16 * 1024 * 1024; -constexpr int STATUS_UNSENT = -1; // the request never left the device - -bool isHttps(const char* url) { return strncmp(url, "https://", 8) == 0; } - -// Returned by value so the TLS client's ~4KB of state is freed with the request rather -// than held for the life of the app. -struct Session { - WiFiClient plain; - WiFiClientSecure secure; - HTTPClient http; - - bool begin(const char* url) { - if (isHttps(url)) { - secure.setCACertBundle(rootca_crt_bundle_start); - secure.setTimeout(TIMEOUT_MS / 1000); - if (!http.begin(secure, url)) return false; - } else if (!http.begin(plain, url)) { - return false; - } - http.setTimeout(TIMEOUT_MS); - http.setConnectTimeout(TIMEOUT_MS); - http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); - http.setRedirectLimit(REDIRECT_LIMIT); - http.setUserAgent("slate32"); - return true; - } -}; - -void applyHeaders(lua_State* L, int index, HTTPClient& http) { - if (!lua_istable(L, index)) return; - lua_pushnil(L); - while (lua_next(L, index) != 0) { - if (lua_isstring(L, -2) && lua_isstring(L, -1)) { - http.addHeader(lua_tostring(L, -2), lua_tostring(L, -1)); - } - lua_pop(L, 1); - } -} - -// get/head/delete take headers in slot 2; post/patch take a body there and headers in 3. -// A string in slot 2 of a bodyless method is rejected rather than reinterpreted, since -// a mistyped headers table would otherwise become a silent protocol error. -int request(lua_State* L, const char* method, bool bodyExpected) { - const char* url = luaL_checkstring(L, 1); - const char* body = ""; - int headerIndex = 2; - if (bodyExpected) { - body = luaL_optstring(L, 2, ""); - headerIndex = 3; - } else if (lua_isstring(L, 2)) { - return luaL_error(L, "%s takes a headers table, not a string", method); - } - if (!lua_isnoneornil(L, headerIndex)) luaL_checktype(L, headerIndex, LUA_TTABLE); - - Session session; - if (!session.begin(url)) { - lua_pushnil(L); - lua_pushinteger(L, STATUS_UNSENT); - return 2; - } - applyHeaders(L, headerIndex, session.http); - - int status = session.http.sendRequest(method, (uint8_t*)body, strlen(body)); - if (status <= 0) { - lua_pushnil(L); - lua_pushinteger(L, status == 0 ? STATUS_UNSENT : status); - return 2; - } - - int size = session.http.getSize(); - if (size > (int)MAX_RESPONSE) { - lua_pushnil(L); - lua_pushinteger(L, status); - return 2; - } - String payload = session.http.getString(); - if (payload.length() > MAX_RESPONSE) { - lua_pushnil(L); - lua_pushinteger(L, status); - return 2; - } - lua_pushlstring(L, payload.c_str(), payload.length()); - lua_pushinteger(L, status); - return 2; -} - -int l_http_get(lua_State* L) { return request(L, "GET", false); } -int l_http_head(lua_State* L) { return request(L, "HEAD", false); } -int l_http_delete(lua_State* L) { return request(L, "DELETE", false); } -int l_http_post(lua_State* L) { return request(L, "POST", true); } -int l_http_patch(lua_State* L) { return request(L, "PATCH", true); } - -int fail(lua_State* L, const char* message) { - lua_pushnil(L); - lua_pushstring(L, message); - return 2; -} - -// Unknown keys are rejected: a misspelled `sha256` that was quietly ignored would -// report a verified download that verified nothing. -bool readOptions(lua_State* L, int index, uint32_t& maxBytes, uint32_t& expectedSize, - String& sha256, const char*& error) { - luaL_checktype(L, index, LUA_TTABLE); - lua_pushnil(L); - while (lua_next(L, index) != 0) { - const char* key = lua_isstring(L, -2) ? lua_tostring(L, -2) : ""; - if (strcmp(key, "maxBytes") == 0) { - maxBytes = lua_tointeger(L, -1); - } else if (strcmp(key, "expectedSize") == 0) { - expectedSize = lua_tointeger(L, -1); - } else if (strcmp(key, "sha256") == 0) { - sha256 = lua_isstring(L, -1) ? lua_tostring(L, -1) : ""; - } else { - lua_pop(L, 2); - error = "Unknown http.download option"; - return false; - } - lua_pop(L, 1); - } - - if (maxBytes < 1 || maxBytes > MAX_DOWNLOAD_BYTES) { - error = "Download maxBytes must be between 1 and 16777216"; - return false; - } - if (expectedSize > maxBytes) { - error = "Download expectedSize exceeds maxBytes"; - return false; - } - if (sha256.length()) { - if (sha256.length() != 64) { - error = "Download sha256 must be 64 hex characters"; - return false; - } - for (size_t i = 0; i < 64; i++) { - if (!isxdigit((unsigned char)sha256[i])) { - error = "Download sha256 must be 64 hex characters"; - return false; - } - } - } - return true; -} - -// Hashed by re-reading rather than while streaming, because the write is HTTPClient's -// loop now. A firmware image costs about a second of SD reads, once, on the path that -// is already downloading megabytes. -bool hashFile(const char* path, uint8_t digest[32]) { - File file = SD.open(path); - if (!file) return false; - mbedtls_sha256_context sha; - mbedtls_sha256_init(&sha); - mbedtls_sha256_starts_ret(&sha, 0); - // Static: the loop task's stack is mostly spoken for by TLS. - static uint8_t chunk[DOWNLOAD_CHUNK]; - while (file.available()) { - int read = file.read(chunk, sizeof(chunk)); - if (read <= 0) break; - mbedtls_sha256_update_ret(&sha, chunk, read); - } - file.close(); - mbedtls_sha256_finish_ret(&sha, digest); - mbedtls_sha256_free(&sha); - return true; -} - -String hex(const uint8_t* digest, size_t length) { - String out; - for (size_t i = 0; i < length; i++) { - char pair[3]; - snprintf(pair, sizeof(pair), "%02x", digest[i]); - out += pair; - } - return out; -} - -// Streams to the card: a firmware image cannot fit in a Lua string, which is the whole -// reason this exists next to http.get. -int l_http_download(lua_State* L) { - const char* url = luaL_checkstring(L, 1); - const char* destination = luaL_checkstring(L, 2); - uint32_t maxBytes = 0, expectedSize = 0; - String expectedSha; - const char* error = nullptr; - if (!readOptions(L, 3, maxBytes, expectedSize, expectedSha, error)) return fail(L, error); - - if (!isHttps(url)) return fail(L, "Download requires a valid HTTPS URL"); - if (SD.exists(destination)) return fail(L, "Download destination failed"); - - Session session; - if (!session.begin(url)) return fail(L, "HTTPS download failed"); - int status = session.http.GET(); - if (status != HTTP_CODE_OK) return fail(L, "HTTPS download failed"); - - // Refused before a byte is written when the server declares the size up front. - int declared = session.http.getSize(); - if (declared > 0 && (uint32_t)declared > maxBytes) return fail(L, "Download exceeded maxBytes"); - - File out = SD.open(destination, FILE_WRITE); - if (!out) return fail(L, "Download destination failed"); - - // HTTPClient's own reader handles chunked encoding, short reads and idle timeouts. - // A hand-rolled loop here spun forever on a stream that stopped producing. - int result = session.http.writeToStream(&out); - out.close(); - - uint32_t written = result > 0 ? (uint32_t)result : 0; - const char* failure = nullptr; - if (result < 0) failure = "HTTPS download failed"; - if (!failure && written > maxBytes) failure = "Download exceeded maxBytes"; - - uint8_t digest[32]; - if (!failure && expectedSha.length()) hashFile(destination, digest); - - if (!failure && expectedSize && written != expectedSize) { - failure = "Downloaded size did not match expectedSize"; - } - if (!failure && expectedSha.length() && !expectedSha.equalsIgnoreCase(hex(digest, sizeof(digest)))) { - failure = "Downloaded SHA-256 did not match"; - } - if (!failure && written == 0) failure = "Download aborted"; - - // A partial or unverified file must not survive: the next boot would treat it as good. - if (failure) { - SD.remove(destination); - return fail(L, failure); - } - lua_pushinteger(L, written); - return 1; -} - -int l_http_urlencode(lua_State* L) { - size_t length; - const char* input = luaL_checklstring(L, 1, &length); - String out; - for (size_t i = 0; i < length; i++) { - char c = input[i]; - if (isalnum((unsigned char)c) || c == '-' || c == '_' || c == '.' || c == '~') { - out += c; - } else { - char escaped[4]; - snprintf(escaped, sizeof(escaped), "%%%02X", (unsigned char)c); - out += escaped; - } - } - lua_pushlstring(L, out.c_str(), out.length()); - return 1; -} - -} // namespace - -void registerHttp(lua_State* L) { - static const luaL_Reg lib[] = { - // --- Fetches a URL. Certificates are verified against the embedded root bundle. - // @param url string - // @param headers table|nil Header name to value. - // @return string|nil Response body, or nil when the status is not 2xx or the body exceeds 50000 bytes. - // @return integer HTTP status, or -1 when the request could not be sent. - {"get", l_http_get}, - // --- Fetches a URL, discarding the body. - // @param url string - // @param headers table|nil Header name to value. - // @return string|nil Response body, always empty on success. - // @return integer HTTP status, or -1 when the request could not be sent. - {"head", l_http_head}, - // --- Deletes a resource. - // @param url string - // @param headers table|nil Header name to value. - // @return string|nil Response body. - // @return integer HTTP status, or -1 when the request could not be sent. - {"delete", l_http_delete}, - // --- Posts a body to a URL. - // @param url string - // @param body string|nil Request body, empty when omitted. - // @param headers table|nil Header name to value. - // @return string|nil Response body. - // @return integer HTTP status, or -1 when the request could not be sent. - {"post", l_http_post}, - // --- Patches a resource. - // @param url string - // @param body string|nil Request body, empty when omitted. - // @param headers table|nil Header name to value. - // @return string|nil Response body. - // @return integer HTTP status, or -1 when the request could not be sent. - {"patch", l_http_patch}, - // --- Streams an HTTPS URL to a file, checking size and digest before keeping it. - // @param url string Must be https. - // @param destination string Absolute path that must not already exist. - // @param options table maxBytes is required; expectedSize and sha256 are optional. - // @return integer|nil Bytes written, or nil on failure. - // @return string|nil Error message when the download failed. - {"download", l_http_download}, - // --- Percent-encodes a string, keeping the RFC 3986 unreserved characters. - // @param input string - // @return string - {"urlencode", l_http_urlencode}, - {nullptr, nullptr}}; - luaL_newlib(L, lib); - lua_setglobal(L, "http"); -} diff --git a/src/lua/bindings/input.cpp b/src/lua/bindings/input.cpp deleted file mode 100644 index 0540414..0000000 --- a/src/lua/bindings/input.cpp +++ /dev/null @@ -1,54 +0,0 @@ -// Touch panel reads. Coordinates are calibrated and rotated by LuaApp::mapTouch; -// the raw ADC pair is exposed separately because calibration cannot use itself. - -#include "../bindings.h" -#include "../lua_app.h" - -static int l_input_getTouch(lua_State* L) { - LuaApp* owner = app(L); - if (!owner->touch.touched()) { - lua_pushnil(L); - return 1; - } - TS_Point point = owner->touch.getPoint(); - int16_t x, y; - owner->mapTouch(point, x, y); - lua_pushinteger(L, x); - lua_pushinteger(L, y); - return 2; -} - -static int l_input_getRawTouch(lua_State* L) { - LuaApp* owner = app(L); - if (!owner->touch.touched()) { - lua_pushnil(L); - return 1; - } - TS_Point point = owner->touch.getPoint(); - lua_pushinteger(L, point.y); - lua_pushinteger(L, point.x); - return 2; -} - -static int l_input_touched(lua_State* L) { - lua_pushboolean(L, app(L)->touch.touched()); - return 1; -} - -void registerInput(lua_State* L) { - static const luaL_Reg lib[] = { - // --- Current touch point, calibrated and rotated. - // @return integer|nil X, or nil when the panel is not touched. - // @return integer|nil Y. - {"getTouch", l_input_getTouch}, - // --- Current touch point as raw ADC readings, for calibration. - // @return integer|nil X, or nil when the panel is not touched. - // @return integer|nil Y. - {"getRawTouch", l_input_getRawTouch}, - // --- Whether the panel is being touched. - // @return boolean - {"isTouched", l_input_touched}, - {nullptr, nullptr}}; - luaL_newlib(L, lib); - lua_setglobal(L, "input"); -} diff --git a/src/lua/bindings/node.cpp b/src/lua/bindings/node.cpp deleted file mode 100644 index 46d2eab..0000000 --- a/src/lua/bindings/node.cpp +++ /dev/null @@ -1,469 +0,0 @@ -// The retained widget tree. Lua describes a screen once and gets integer handles back; -// the nodes themselves live in a flat C++ arena at 16 bytes each, where the same tree -// as Lua tables cost roughly forty times that. -// -// This namespace owns structure and geometry only. Composition (ui.confirm, ui.label), -// the palette, and press callbacks stay in /lib/ui.lua -- a callback keyed by node id in -// an ordinary Lua table is cheaper than a registry reference and far less bookkeeping. - -#include - -#include "../../ui/paint.h" -#include "../bindings.h" -#include "../lua_app.h" - -// The Lua function that paints CUSTOM nodes, if the toolkit installed one. A single -// registry slot for the whole tree: Lua keys its own painters by node id. -static const char* PAINTER_KEY = "slate32.node.painter"; - -static ui::Tree& tree(lua_State* L) { return app(L)->tree; } - -// Every id crosses from Lua, so every id is checked: a stale handle must raise a Lua -// error, not index past the end of the arena. -static uint16_t checkId(lua_State* L, int index) { - lua_Integer id = luaL_checkinteger(L, index); - luaL_argcheck(L, id >= 0 && static_cast(id) < tree(L).nodes.size(), index, - "no such node"); - return static_cast(id); -} - -// Pixels (>= 1), a fraction of the parent content box (< 1), "fill" for all of it, or -// "auto" to size to content. Fractions are carried as per-mille so layout stays integral. -static ui::Size decodeSize(lua_State* L, int index) { - if (lua_isnumber(L, index)) { - double value = lua_tonumber(L, index); - return value < 1.0 ? ui::Size::fraction(static_cast(value * 1000.0 + 0.5)) - : ui::Size::px(static_cast(value)); - } - if (lua_isstring(L, index) && strcmp(lua_tostring(L, index), "fill") == 0) { - return ui::Size::fill(); - } - return ui::Size(); -} - -static ui::Size readSize(lua_State* L, int table, const char* key) { - lua_getfield(L, table, key); - ui::Size size = decodeSize(L, -1); - lua_pop(L, 1); - return size; -} - -static int readInt(lua_State* L, int table, const char* key, int fallback = 0) { - lua_getfield(L, table, key); - int value = lua_isnumber(L, -1) ? static_cast(lua_tointeger(L, -1)) : fallback; - lua_pop(L, 1); - return value; -} - -static bool readBool(lua_State* L, int table, const char* key) { - lua_getfield(L, table, key); - bool value = lua_toboolean(L, -1); - lua_pop(L, 1); - return value; -} - -static ui::Align readAlign(lua_State* L, int table, const char* key) { - lua_getfield(L, table, key); - const char* name = lua_isstring(L, -1) ? lua_tostring(L, -1) : "start"; - ui::Align align = ui::START; - if (strcmp(name, "center") == 0) { - align = ui::CENTER; - } else if (strcmp(name, "end") == 0) { - align = ui::END; - } else if (strcmp(name, "between") == 0) { - align = ui::BETWEEN; - } - lua_pop(L, 1); - return align; -} - -// A number pads all four sides; a table names the ones it wants. -static void readPad(lua_State* L, int table, ui::Spec& spec) { - lua_getfield(L, table, "pad"); - if (lua_isnumber(L, -1)) { - uint8_t all = static_cast(lua_tointeger(L, -1)); - spec.padT = spec.padR = spec.padB = spec.padL = all; - } else if (lua_istable(L, -1)) { - int pad = lua_gettop(L); - spec.padT = static_cast(readInt(L, pad, "t")); - spec.padR = static_cast(readInt(L, pad, "r")); - spec.padB = static_cast(readInt(L, pad, "b")); - spec.padL = static_cast(readInt(L, pad, "l")); - } - lua_pop(L, 1); -} - -static uint8_t readType(lua_State* L, int table) { - lua_getfield(L, table, "type"); - const char* name = lua_isstring(L, -1) ? lua_tostring(L, -1) : "box"; - uint8_t type = ui::BOX; - if (strcmp(name, "text") == 0) { - type = ui::TEXT; - } else if (strcmp(name, "button") == 0) { - type = ui::BUTTON; - } else if (strcmp(name, "custom") == 0) { - type = ui::CUSTOM; - } - lua_pop(L, 1); - return type; -} - -static int l_node_reset(lua_State* L) { - tree(L).reset(); - return 0; -} - -static int l_node_create(lua_State* L) { - uint16_t parent = lua_isnoneornil(L, 1) ? ui::NONE : checkId(L, 1); - luaL_checktype(L, 2, LUA_TTABLE); - int spec_index = 2; - - ui::Spec spec; - spec.w = readSize(L, spec_index, "w"); - spec.h = readSize(L, spec_index, "h"); - spec.gap = static_cast(readInt(L, spec_index, "gap")); - spec.align = readAlign(L, spec_index, "align"); - spec.justify = readAlign(L, spec_index, "justify"); - readPad(L, spec_index, spec); - - lua_getfield(L, spec_index, "at"); - if (lua_istable(L, -1)) { - spec.absolute = true; - spec.atX = readSize(L, lua_gettop(L), "x"); - spec.atY = readSize(L, lua_gettop(L), "y"); - } - lua_pop(L, 1); - - uint8_t flags = 0; - if (readBool(L, spec_index, "row")) flags |= ui::ROW; - if (readBool(L, spec_index, "capture")) flags |= ui::CAPTURE; - if (readBool(L, spec_index, "interactive")) flags |= ui::INTERACTIVE; - - // Text is measured here because the panel owns the font: a node sized in Lua would - // need the metrics bindings for every label on every build. - uint8_t type = readType(L, spec_index); - lua_getfield(L, spec_index, "label"); - const char* label = lua_tostring(L, -1); - if (label) { - int previous = app(L)->tft.textsize; - app(L)->tft.setTextSize(readInt(L, spec_index, "size", 1)); - spec.intrinsicW = static_cast(app(L)->tft.textWidth(label)); - spec.intrinsicH = static_cast(app(L)->tft.fontHeight()); - app(L)->tft.setTextSize(previous); - } - - uint16_t id = tree(L).add(parent, spec, type, flags); - if (label) tree(L).setLabel(id, label); - lua_pop(L, 1); - - lua_pushinteger(L, id); - return 1; -} - -static int l_node_attach(lua_State* L) { - uint16_t parent = checkId(L, 1); - uint16_t child = checkId(L, 2); - luaL_argcheck(L, child != parent, 2, "a node cannot hold itself"); - luaL_argcheck(L, tree(L).nodes[child].parent == ui::NONE, 2, "node already has a parent"); - tree(L).attach(parent, child); - return 0; -} - -// Only fills in what a node left to "auto", which is how a screen root claims the panel -// without overriding a size its caller actually asked for. -static int l_node_setSize(lua_State* L) { - uint16_t id = checkId(L, 1); - luaL_argcheck(L, id < tree(L).specs.size(), 1, "tree already laid out"); - ui::Spec& spec = tree(L).specs[id]; - if (spec.w.mode == ui::AUTO) spec.w = decodeSize(L, 2); - if (spec.h.mode == ui::AUTO) spec.h = decodeSize(L, 3); - return 0; -} - -static int l_node_layout(lua_State* L) { - uint16_t root = checkId(L, 1); - bool ok = tree(L).layout(root, luaL_checkinteger(L, 2), luaL_checkinteger(L, 3), - luaL_checkinteger(L, 4), luaL_checkinteger(L, 5)); - lua_pushboolean(L, ok); - if (ok) return 1; - lua_pushstring(L, tree(L).error ? tree(L).error : "layout failed"); - return 2; -} - -static int l_node_dropScratch(lua_State* L) { - tree(L).dropScratch(); - return 0; -} - -static int l_node_hit(lua_State* L) { - uint16_t root = checkId(L, 1); - uint16_t found = tree(L).hit(root, luaL_checkinteger(L, 2), luaL_checkinteger(L, 3)); - if (found == ui::NONE) return 0; - lua_pushinteger(L, found); - return 1; -} - -static int l_node_getRect(lua_State* L) { - const ui::Node& n = tree(L).nodes[checkId(L, 1)]; - lua_pushinteger(L, n.x); - lua_pushinteger(L, n.y); - lua_pushinteger(L, n.w); - lua_pushinteger(L, n.h); - return 4; -} - -static int l_node_setLabel(lua_State* L) { - uint16_t id = checkId(L, 1); - tree(L).setLabel(id, luaL_checkstring(L, 2)); - return 0; -} - -static int l_node_getLabel(lua_State* L) { - const char* label = tree(L).label(checkId(L, 1)); - if (!label) return 0; - lua_pushstring(L, label); - return 1; -} - -static int l_node_getParent(lua_State* L) { - uint16_t parent = tree(L).nodes[checkId(L, 1)].parent; - if (parent == ui::NONE) return 0; - lua_pushinteger(L, parent); - return 1; -} - -static int l_node_getCount(lua_State* L) { - lua_pushinteger(L, static_cast(tree(L).nodes.size())); - return 1; -} - -static int l_node_setStyle(lua_State* L) { - uint16_t id = checkId(L, 1); - luaL_checktype(L, 2, LUA_TTABLE); - ui::Style& style = tree(L).styleFor(id); - - struct Role { - const char* key; - uint16_t field; - uint16_t ui::Style::*slot; - }; - static const Role roles[] = { - {"color", ui::S_FG, &ui::Style::fg}, - {"bg", ui::S_BG, &ui::Style::bg}, - {"fill", ui::S_FILL, &ui::Style::fill}, - {"border", ui::S_BORDER, &ui::Style::border}, - {"press_color", ui::S_PRESS_FG, &ui::Style::pressFg}, - }; - for (const Role& role : roles) { - lua_getfield(L, 2, role.key); - if (lua_isnumber(L, -1)) { - style.*(role.slot) = static_cast(lua_tointeger(L, -1)); - style.set |= role.field; - } - lua_pop(L, 1); - } - - // Gradients arrive as the {top, bottom} pair the palette derives them in, so a theme - // cannot set half of one. - struct Pair { - const char* key; - uint16_t field; - uint16_t ui::Style::*top; - uint16_t ui::Style::*bottom; - }; - static const Pair pairs[] = { - {"face", ui::S_FACE, &ui::Style::faceTop, &ui::Style::faceBottom}, - {"face_pressed", ui::S_PRESSED, &ui::Style::pressTop, &ui::Style::pressBottom}, - }; - for (const Pair& pair : pairs) { - lua_getfield(L, 2, pair.key); - if (lua_istable(L, -1)) { - lua_rawgeti(L, -1, 1); - lua_rawgeti(L, -2, 2); - style.*(pair.top) = static_cast(lua_tointeger(L, -2)); - style.*(pair.bottom) = static_cast(lua_tointeger(L, -1)); - style.set |= pair.field; - lua_pop(L, 2); - } - lua_pop(L, 1); - } - - lua_getfield(L, 2, "radius"); - if (lua_isnumber(L, -1)) { - style.radius = static_cast(lua_tointeger(L, -1)); - style.set |= ui::S_RADIUS; - } - lua_pop(L, 1); - - lua_getfield(L, 2, "size"); - if (lua_isnumber(L, -1)) { - style.size = static_cast(lua_tointeger(L, -1)); - style.set |= ui::S_SIZE; - } - lua_pop(L, 1); - - lua_getfield(L, 2, "text_align"); - bool hasAlign = lua_isstring(L, -1); - lua_pop(L, 1); - if (hasAlign) { - style.textAlign = readAlign(L, 2, "text_align"); - style.set |= ui::S_TEXT_ALIGN; - } - return 0; -} - -static int l_node_invalidate(lua_State* L) { - tree(L).nodes[checkId(L, 1)].flags |= ui::DIRTY; - return 0; -} - -static int l_node_setPressed(lua_State* L) { - ui::Node& n = tree(L).nodes[checkId(L, 1)]; - if (lua_toboolean(L, 2)) { - n.flags |= ui::PRESSED; - } else { - n.flags &= ~ui::PRESSED; - } - n.flags |= ui::DIRTY; - return 0; -} - -static int l_node_isPressed(lua_State* L) { - lua_pushboolean(L, (tree(L).nodes[checkId(L, 1)].flags & ui::PRESSED) != 0); - return 1; -} - -static int l_node_setPainter(lua_State* L) { - luaL_checktype(L, 1, LUA_TFUNCTION); - lua_pushvalue(L, 1); - lua_setfield(L, LUA_REGISTRYINDEX, PAINTER_KEY); - return 0; -} - -static void callPainter(void* context, uint16_t id, int x, int y, int w, int h) { - lua_State* L = static_cast(context); - lua_getfield(L, LUA_REGISTRYINDEX, PAINTER_KEY); - if (!lua_isfunction(L, -1)) { - lua_pop(L, 1); - return; - } - lua_pushinteger(L, id); - lua_pushinteger(L, x); - lua_pushinteger(L, y); - lua_pushinteger(L, w); - lua_pushinteger(L, h); - // A painter that errors must not abandon the rest of the tree unpainted, which is why - // this is pcall: one broken widget leaves a hole, not a blank screen. - if (lua_pcall(L, 5, 0, 0) != LUA_OK) { - log_e("node painter: %s", lua_tostring(L, -1)); - lua_pop(L, 1); - } -} - -static int l_node_draw(lua_State* L) { - uint16_t root = checkId(L, 1); - ui::Painter painter(app(L)->tft, tree(L)); - painter.custom = callPainter; - painter.context = L; - painter.draw(root); - return 0; -} - -static int l_node_getFootprint(lua_State* L) { - lua_pushinteger(L, static_cast(tree(L).footprint())); - return 1; -} - -void registerNode(lua_State* L) { - static const luaL_Reg lib[] = { - // --- Drops the whole tree. Every screen is built from scratch, so this is what a - // --- rebuild starts with; existing ids are invalid afterwards. - {"reset", l_node_reset}, - // --- Adds a node and returns its handle. - // @param parent integer|nil Nil creates a root. - // @param spec table Fields: type ("box", "text", "button", "custom"), w, h, pad, - // @param spec table gap, align, justify, row, at, capture, interactive, label, size. - // @return integer - {"create", l_node_create}, - // --- Adopts an existing root node as a child, so a container can be built after - // --- the things it holds. The child must not already have a parent. - // @param parent integer - // @param child integer - {"attach", l_node_attach}, - // --- Fills in sizes a node left as "auto". Only meaningful before layout. - // @param id integer - // @param w number|string|nil - // @param h number|string|nil - {"setSize", l_node_setSize}, - // --- Measures and places a tree into the given rectangle. - // @param root integer - // @param x integer - // @param y integer - // @param w integer - // @param h integer - // @return boolean True on success; false plus a message when a size cannot resolve. - {"layout", l_node_layout}, - // --- Frees the layout inputs, which nothing reads once a tree is placed. A screen - // --- calls this after layout and rebuilds from Lua if it ever needs placing again. - {"dropScratch", l_node_dropScratch}, - // --- Deepest interactive node covering the point, or nil. - // @param root integer - // @param x integer - // @param y integer - // @return integer|nil - {"hit", l_node_hit}, - // --- Placed rectangle of a node. - // @param id integer - // @return integer x - // @return integer y - // @return integer w - // @return integer h - {"getRect", l_node_getRect}, - // --- Replaces a node's text. Re-measuring is the caller's business: the node keeps - // --- the box it was placed with until the screen is rebuilt. - // @param id integer - // @param text string - {"setLabel", l_node_setLabel}, - // --- A node's text, or nil if it has none. - // @param id integer - // @return string|nil - {"getLabel", l_node_getLabel}, - // --- The node that contains this one, or nil at the root. - // @param id integer - // @return integer|nil - {"getParent", l_node_getParent}, - // --- Sets the colors and metrics a node states for itself. Anything left out is - // --- answered by the nearest ancestor that states it, so a node that names nothing - // --- costs nothing. - // @param id integer - // @param style table Fields: color, bg, fill, border, press_color, radius, size, - // @param style table and the {top, bottom} pairs face and face_pressed. - {"setStyle", l_node_setStyle}, - // --- Marks a node for repainting on the next draw, along with its children. - // @param id integer - {"invalidate", l_node_invalidate}, - // --- Shows or clears a node's pressed face. - // @param id integer - // @param on boolean - {"setPressed", l_node_setPressed}, - // --- Whether a node is currently showing its pressed face. - // @param id integer - // @return boolean - {"isPressed", l_node_isPressed}, - // --- Installs the function that paints "custom" nodes, called with the node id and - // --- its placed rectangle. One painter serves the whole tree. - // @param painter function - {"setPainter", l_node_setPainter}, - // --- Repaints every node marked dirty, and everything inside one. - // @param root integer - {"draw", l_node_draw}, - // --- How many nodes the current tree holds. - // @return integer - {"getCount", l_node_getCount}, - // --- Bytes the current tree occupies, for the memory the design exists to save. - // @return integer - {"getFootprint", l_node_getFootprint}, - {nullptr, nullptr}}; - luaL_newlib(L, lib); - lua_setglobal(L, "node"); -} diff --git a/src/lua/bindings/settings.cpp b/src/lua/bindings/settings.cpp deleted file mode 100644 index dba930e..0000000 --- a/src/lua/bindings/settings.cpp +++ /dev/null @@ -1,112 +0,0 @@ -// 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(); - app(L)->refreshStatusBar(); // every slot in the bar just moved - 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 deleted file mode 100644 index 3b98670..0000000 --- a/src/lua/bindings/sys.cpp +++ /dev/null @@ -1,151 +0,0 @@ -// Process control and runtime state, plus the one-function `log` table. Persisted -// preferences live in the `settings` table, not here. - -#include "../../net.h" -#include "../bindings.h" -#include "../lua_app.h" - -static constexpr size_t MAX_APP_NAME = 32; -static constexpr size_t MAX_LAUNCH_ARG = 256; - -static int l_sys_millis(lua_State* L) { - lua_pushinteger(L, millis()); - return 1; -} - -static int l_sys_back(lua_State* L) { - app(L)->requestBack(); - return 0; -} - -static int l_sys_appName(lua_State* L) { - lua_pushstring(L, app(L)->name().c_str()); - return 1; -} - -static int navigate(lua_State* L, bool replace) { - const char* arg = lua_isnoneornil(L, 2) ? nullptr : luaL_checkstring(L, 2); - if (arg && strlen(arg) > MAX_LAUNCH_ARG) return luaL_error(L, "launch argument too long"); - app(L)->requestLaunch(luaL_checkstring(L, 1), arg, replace); - return 0; -} - -static int l_sys_launch(lua_State* L) { return navigate(L, false); } -static int l_sys_replace(lua_State* L) { return navigate(L, true); } - -// Renames the running app for the status bar, which is the only thing that reads it. Kept -// as a setter rather than a constant the chunk declares: a screen that retitles itself -// ("settings" to "settings - wifi") needs it to change after init(). -static int l_sys_setAppName(lua_State* L) { - size_t length; - const char* name = luaL_checklstring(L, 1, &length); - if (!length || length > MAX_APP_NAME) return luaL_error(L, "app name must be 1 to 32 bytes"); - app(L)->setName(String(name, length)); - app(L)->refreshStatusBar(); - return 0; -} - -// 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) { - lua_pushboolean(L, net::clockSynced()); - return 1; -} - -static int l_sys_memory(lua_State* L) { - lua_pushinteger(L, ESP.getFreeHeap()); - lua_pushinteger(L, ESP.getHeapSize()); - lua_pushinteger(L, ESP.getMaxAllocHeap()); - return 3; -} - -static int logAt(lua_State* L, const char* level) { - Serial.printf("[lua:%s] %s\n", level, luaL_checkstring(L, 1)); - return 0; -} - -// Rejected rather than ignored when on_tick is absent: the chunk body has already run -// by the time init() calls this, so a missing callback is a typo, not a race. -static int l_sys_setTickInterval(lua_State* L) { - lua_Integer requested = luaL_checkinteger(L, 1); - if (requested <= 0) { - app(L)->setTickInterval(0); - return 0; - } - lua_getglobal(L, "on_tick"); - bool hasTick = lua_isfunction(L, -1); - lua_pop(L, 1); - if (!hasTick) return luaL_error(L, "sys.setTickInterval() requires on_tick()"); - - uint32_t interval = requested < (lua_Integer)LuaApp::MIN_TICK_MS ? LuaApp::MIN_TICK_MS - : requested > (lua_Integer)LuaApp::MAX_TICK_MS ? LuaApp::MAX_TICK_MS - : (uint32_t)requested; - app(L)->setTickInterval(interval); - return 0; -} - -static int l_log_debug(lua_State* L) { return logAt(L, "debug"); } -static int l_log_info(lua_State* L) { return logAt(L, "info"); } -static int l_log_error(lua_State* L) { return logAt(L, "error"); } - -// Sleeping is the one thing a script cannot express itself, since the runtime owns -// the loop. -static int l_sys_delay(lua_State* L) { - delay(luaL_checkinteger(L, 1)); - return 0; -} - -void registerSys(lua_State* L) { - static const luaL_Reg lib[] = { - // --- Milliseconds since boot. - // @return integer - {"getMillis", l_sys_millis}, - // --- Blocks for the given time. - // @param ms integer - {"delay", l_sys_delay}, - // --- Returns to the previous app, or Home when there is no history. - {"back", l_sys_back}, - // --- Name of the running app, its directory name until sys.setAppName() changes it. - // @return string - {"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. - // @param arg string|nil Passed to the new app's init(); at most 256 bytes. - {"launch", l_sys_launch}, - // --- Starts another app without adding this route to history. - // @param path string Absolute path to the app's main.lua. - // @param arg string|nil Passed to the new app's init(); at most 256 bytes. - {"replace", l_sys_replace}, - // --- Renames the running app in the status bar. - // @param name string 1 to 32 bytes. - {"setAppName", l_sys_setAppName}, - // --- Free and total heap, in bytes. - // @return integer Free bytes. - // @return integer Total bytes. - // @return integer Largest contiguous free block. - {"getMemory", l_sys_memory}, - // --- Whether SNTP has answered. Until it has, os.time() is only a build-time floor. - // @return boolean - {"isClockSynced", l_sys_clockSynced}, - {nullptr, nullptr}}; - luaL_newlib(L, lib); - lua_setglobal(L, "sys"); - - // Too small to deserve its own translation unit. - static const luaL_Reg logLib[] = { - // --- Writes a debug line to the serial log. - // @param message string - {"debug", l_log_debug}, - // --- Writes an info line to the serial log. - // @param message string - {"info", l_log_info}, - // --- Writes an error line to the serial log. - // @param message string - {"error", l_log_error}, - {nullptr, nullptr}}; - luaL_newlib(L, logLib); - lua_setglobal(L, "log"); -} diff --git a/src/lua/bindings/wifi.cpp b/src/lua/bindings/wifi.cpp deleted file mode 100644 index 16fb336..0000000 --- a/src/lua/bindings/wifi.cpp +++ /dev/null @@ -1,142 +0,0 @@ -// Station-mode WiFi. Credentials live in the same settings file as everything else, -// so connecting persists them and a saved network reconnects at boot. - -#include - -#include "../../settings.h" -#include "../bindings.h" -#include "../lua_app.h" - -static constexpr uint32_t CONNECT_TIMEOUT_MS = 15000; -static constexpr size_t MAX_SSID = 32; -static constexpr size_t MAX_PASSWORD = 64; - -// Arduino reports WL_DISCONNECTED both before an attempt and after a failed one, so -// the attempt start is tracked here to tell "still trying" from "gave up". -static uint32_t connectStartedAt; - -static int l_wifi_scan(lua_State* L) { - WiFi.mode(WIFI_STA); - int count = WiFi.scanNetworks(false, true); - lua_newtable(L); - if (count < 0) return 1; - for (int i = 0; i < count; i++) { - lua_newtable(L); - lua_pushstring(L, WiFi.SSID(i).c_str()); - lua_setfield(L, -2, "ssid"); - lua_pushinteger(L, WiFi.RSSI(i)); - lua_setfield(L, -2, "rssi"); - lua_pushboolean(L, WiFi.encryptionType(i) != WIFI_AUTH_OPEN); - lua_setfield(L, -2, "secure"); - lua_rawseti(L, -2, i + 1); - } - WiFi.scanDelete(); - return 1; -} - -static int l_wifi_connect(lua_State* L) { - size_t ssidLength, passwordLength; - const char* ssid = luaL_checklstring(L, 1, &ssidLength); - const char* password = luaL_optlstring(L, 2, "", &passwordLength); - if (!ssidLength || ssidLength > MAX_SSID || passwordLength > MAX_PASSWORD) { - lua_pushboolean(L, false); - return 1; - } - settings.wifiSsid = String(ssid, ssidLength); - settings.wifiPassword = String(password, passwordLength); - if (!settings.save()) { - lua_pushboolean(L, false); - return 1; - } - WiFi.mode(WIFI_STA); - WiFi.begin(settings.wifiSsid.c_str(), settings.wifiPassword.c_str()); - connectStartedAt = millis(); - lua_pushboolean(L, true); - return 1; -} - -static const char* connectionState(wl_status_t status) { - if (status == WL_CONNECTED) { - connectStartedAt = 0; - return "connected"; - } - if (status == WL_NO_SSID_AVAIL) return "not_found"; - if (status == WL_CONNECT_FAILED) return "failed"; - if (!connectStartedAt) return "disconnected"; - return millis() - connectStartedAt >= CONNECT_TIMEOUT_MS ? "failed" : "connecting"; -} - -static int l_wifi_status(lua_State* L) { - wl_status_t status = WiFi.status(); - // A boot-time reconnect starts before any Lua call, so adopt it as an attempt. - if (!connectStartedAt && settings.wifiSsid.length() && status != WL_CONNECTED) { - connectStartedAt = millis(); - } - - lua_newtable(L); - lua_pushstring(L, connectionState(status)); - lua_setfield(L, -2, "state"); - lua_pushstring(L, status == WL_CONNECTED ? WiFi.SSID().c_str() : settings.wifiSsid.c_str()); - lua_setfield(L, -2, "ssid"); - lua_pushstring(L, status == WL_CONNECTED ? WiFi.localIP().toString().c_str() : ""); - lua_setfield(L, -2, "ip"); - lua_pushinteger(L, status == WL_CONNECTED ? WiFi.RSSI() : 0); - lua_setfield(L, -2, "rssi"); - return 1; -} - -// Shorthands for two fields status() already carries, for scripts that want one answer -// without unpacking a table. -static int l_wifi_isConnected(lua_State* L) { - lua_pushboolean(L, WiFi.status() == WL_CONNECTED); - return 1; -} - -static int l_wifi_localIP(lua_State* L) { - lua_pushstring(L, WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString().c_str() : "0.0.0.0"); - return 1; -} - -static int l_wifi_disconnect(lua_State* L) { - WiFi.disconnect(false, false); // keeps the saved credentials, unlike forget() - return 0; -} - -static int l_wifi_forget(lua_State* L) { - WiFi.disconnect(true, false); - settings.wifiSsid = ""; - settings.wifiPassword = ""; - connectStartedAt = 0; - lua_pushboolean(L, settings.save()); - return 1; -} - -void registerWifi(lua_State* L) { - static const luaL_Reg lib[] = { - // --- Scans for networks, blocking until the sweep finishes. - // @return table[] Each entry has ssid, rssi and secure. - {"scan", l_wifi_scan}, - // --- Saves credentials and starts connecting. Poll status() for the outcome. - // @param ssid string - // @param password string|nil Omitted for an open network. - // @return boolean Whether the credentials were saved and the attempt started. - {"connect", l_wifi_connect}, - // --- Current connection state. - // @return table Fields state, ssid, ip and rssi. state is one of disconnected, - // --- connecting, connected, not_found or failed. - {"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. - {"getLocalIP", l_wifi_localIP}, - // --- Drops the connection but keeps the saved credentials. - {"disconnect", l_wifi_disconnect}, - // --- Drops the connection and erases the saved credentials. - // @return boolean Whether the settings were saved. - {"forget", l_wifi_forget}, - {nullptr, nullptr}}; - luaL_newlib(L, lib); - lua_setglobal(L, "wifi"); -} diff --git a/src/lua/lua_app.cpp b/src/lua/lua_app.cpp deleted file mode 100644 index 86ea8a7..0000000 --- a/src/lua/lua_app.cpp +++ /dev/null @@ -1,337 +0,0 @@ -// App lifecycle: own the lua_State, dispatch the callbacks, and tear down safely. -// The bindings themselves live in bindings/, the loader in module_loader.cpp. - -#include "lua_app.h" - -#include - -#include "../settings.h" -#include "bindings.h" - -extern "C" { -#include -#include -} - -// LUA_EXTRASPACE is a pointer-sized block ahead of every lua_State, and Lua copies it -// into new coroutines, so the owner is reachable from any state without a global. -LuaApp* app(lua_State* L) { return *static_cast(lua_getextraspace(L)); } - -void bindApp(lua_State* L, LuaApp* owner) { *static_cast(lua_getextraspace(L)) = owner; } - -void LuaApp::mapTouch(const TS_Point& p, int16_t& x, int16_t& y) const { - int16_t nx = constrain(map(p.y, settings.touchX0, settings.touchX1, 0, PANEL_W), 0, PANEL_W - 1); - int16_t ny = constrain(map(p.x, settings.touchY0, settings.touchY1, 0, PANEL_H), 0, PANEL_H - 1); - switch (tft.getRotation() & 3) { - case 1: - x = ny; - y = PANEL_W - 1 - nx; - break; - case 2: - x = PANEL_W - 1 - nx; - y = PANEL_H - 1 - ny; - break; - case 3: - x = PANEL_H - 1 - ny; - y = nx; - break; - default: - x = nx; - y = ny; - break; - } - // App space starts below the bar, matching the viewport apps draw into. A tap on the - // bar itself lands at a negative y, which loop() drops. - y -= 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; - applyViewport(); - nextBarMs = 0; // leaving fullscreen left the bar's rows painted by the app -} - -LuaApp::LuaApp(TFT_eSPI& tft, XPT2046_Touchscreen& touch) : tft(tft), touch(touch) {} - -LuaApp::~LuaApp() { - if (state) lua_close(state); -} - -bool LuaApp::hasGlobal(const char* name) { - lua_getglobal(state, name); - bool found = lua_isfunction(state, -1); - lua_pop(state, 1); - return found; -} - -bool LuaApp::callGlobal(const char* name, int nargs) { - if (lua_pcall(state, nargs, 0, 0) != LUA_OK) { - fail(lua_tostring(state, -1)); - return false; - } - return true; -} - -void LuaApp::fireTouch(const char* name, int16_t x, int16_t y) { - if (!hasGlobal(name)) return; - lua_getglobal(state, name); - lua_pushinteger(state, x); - lua_pushinteger(state, y); - callGlobal(name, 2); -} - -// Deliberately not themed: this can fire when the settings naming the theme are -// themselves unreadable, so it stays high contrast. -void LuaApp::fail(const char* message) { - Serial.printf("[lua] error: %s\n", message ? message : "(unknown)"); - tft.fillScreen(TFT_WHITE); - tft.setTextColor(TFT_RED, TFT_WHITE); - tft.drawString("Lua error:", 8, 8); - tft.drawString(message ? message : "(unknown)", 8, 28); - failed = true; - requestExit(); -} - -bool LuaApp::takeFailure() { - bool value = failed; - failed = false; - return value; -} - -bool LuaApp::load(const char* path, bool hasBack, const char* arg) { - closeState(); - this->hasBack = hasBack; - backArmed = false; - // Cleared per app: the interval outlives the app that set it, so a ticking app - // followed by one that never ticks would keep calling a nil global. - tickIntervalMs = 0; - // Same reason, and the arena is the bigger one: node handles mean nothing to the next - // lua_State, so an app that inherited the last one's tree would build onto its nodes. - tree.reset(); - // The tap that launched this app may still be down; swallow that gesture's release. - lastTouched = true; - ignoreRelease = true; - state = luaL_newstate(); - if (!state) return false; - bindApp(state, this); - luaL_openlibs(state); - registerBindings(); - - String appDir = path; - int slash = appDir.lastIndexOf('/'); - appDir = slash > 0 ? appDir.substring(0, slash) : String("/"); - installLoader(appDir.c_str()); - appName = appDir.substring(appDir.lastIndexOf('/') + 1); - - // Before the chunk runs: the bar's height sets the viewport, and an app measuring - // 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)); - return false; - } - if (!callGlobal(path)) return false; // run chunk body - // An app whose entry point is misspelled would otherwise start, draw nothing, and - // give no hint why. - if (!hasGlobal("init")) { - fail("Missing init()"); - return false; - } - lua_getglobal(state, "init"); - if (arg) { - lua_pushstring(state, arg); - } else { - lua_pushnil(state); // pushed either way, so init(arg) reads the same in both cases - } - if (!callGlobal("init", 1)) return false; - return running(); -} - -// Reads an optional positive integer field off the bar module on the stack top. -static lua_Integer barField(lua_State* L, const char* key, lua_Integer fallback) { - lua_getfield(L, -1, key); - lua_Integer value = lua_isinteger(L, -1) ? lua_tointeger(L, -1) : fallback; - lua_pop(L, 1); - return value > 0 ? value : fallback; -} - -// The bar is a Lua module like any other, loaded per app because the state is too. Its -// own table owns the geometry and the repaint rate: sys.setTickInterval() belongs to the -// app, and there is only one of those per state. -void LuaApp::loadStatusBar() { - barBroken = false; - 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", DEFAULT_BAR_H); - barIntervalMs = std::max((lua_Integer)MIN_TICK_MS, barField(state, "interval", BAR_INTERVAL_MS)); - lua_setglobal(state, "__statusbar"); - nextBarMs = 0; -} - -// Errors here are logged and disable the bar rather than killing the app: chrome that -// fails should not take the running program with it. -void LuaApp::drawStatusBar() { - lua_getglobal(state, "__statusbar"); - lua_getfield(state, -1, "draw"); - lua_remove(state, -2); - lua_pushboolean(state, hasBack); // whether to paint the back button - tft.resetViewport(); // the bar paints in panel coordinates, the app does not - if (lua_pcall(state, 1, 0, 0) != LUA_OK) { - Serial.printf("[statusbar] %s\n", luaL_tolstring(state, -1, nullptr)); - lua_pop(state, 2); - barBroken = true; - } - applyViewport(); -} - -void LuaApp::setTickInterval(uint32_t intervalMs) { - tickIntervalMs = intervalMs; - nextTickMs = millis() + intervalMs; -} - -void LuaApp::registerBindings() { - registerGui(state); - registerNode(state); - registerSys(state); - registerSettings(state); - registerInput(state); - registerFs(state); - registerWifi(state); - registerHttp(state); -} - -// Deferred: navigation and fail() run inside a lua_pcall, so closing the state here -// would free the VM that is still executing. -void LuaApp::requestExit() { exitRequested = true; } - -void LuaApp::requestBack() { - exitAction = ExitAction::Back; - requestExit(); -} - -void LuaApp::requestLaunch(const char* path, const char* arg, bool replace) { - exitAction = replace ? ExitAction::Replace : ExitAction::Launch; - pendingLaunch = path; - pendingArg = arg ? arg : ""; - pendingArgSet = arg != nullptr; - requestExit(); -} - -LuaApp::ExitAction LuaApp::takeExitAction() { - ExitAction action = exitAction; - exitAction = ExitAction::None; - return action; -} - -String LuaApp::takePendingLaunch() { - String path = pendingLaunch; - pendingLaunch = ""; - return path; -} - -String LuaApp::takePendingArg() { - String arg = pendingArg; - pendingArg = ""; - pendingArgSet = false; - return arg; -} - -void LuaApp::closeState() { - if (state) { - lua_close(state); - state = nullptr; - } - exitRequested = false; -} - -void LuaApp::loop() { - if (exitRequested) closeState(); - if (!state) return; - uint32_t now = millis(); - - bool touched = touch.touched(); - if (touched) { - TS_Point p = touch.getPoint(); - mapTouch(p, lastX, lastY); - } - - // The bar is the host's: apps never see a touch in it, and its one control acts on - // release, so a press that slides off into the app cancels like any other button. - if (touched && lastY < 0) { - backArmed = backArmed || inBackButton(); - touched = false; - } else if (backArmed) { - backArmed = false; - if (inBackButton()) { - requestBack(); - return; - } - } - if (touched && !lastTouched) { - movedX = lastX; - movedY = lastY; - fireTouch("on_touch_down", lastX, lastY); - if (!running()) return; - } else if (touched && lastTouched) { - // Move Threshold - The XPT2046 jitters a pixel or two under a still finger. Below this - // every poll would fire a move, and a painting app would draw a blur where nothing moved. - if (abs(lastX - movedX) + abs(lastY - movedY) >= MOVE_EPSILON_PX) { - movedX = lastX; - movedY = lastY; - fireTouch("on_touch_move", lastX, lastY); - if (!running()) return; - } - } else if (!touched && lastTouched && ignoreRelease) { - ignoreRelease = false; // the press that launched this app is not its own gesture - } else if (!touched && lastTouched) { - fireTouch("on_touch_up", lastX, lastY); - if (!running()) return; - fireTouch("on_touch", lastX, lastY); // tap alias, fired on release like a click - if (!running()) return; - } - lastTouched = touched; - - if (tickIntervalMs > 0 && now >= nextTickMs) { - nextTickMs = now + tickIntervalMs; - lua_getglobal(state, "on_tick"); - callGlobal("on_tick"); - if (!running()) return; - } - - if (now >= nextDrawMs && hasGlobal("draw")) { - nextDrawMs = now + DRAW_INTERVAL_MS; - lua_getglobal(state, "draw"); - callGlobal("draw"); - if (!running()) return; - } - - if (barInset() > 0 && !barBroken && now >= nextBarMs) { - nextBarMs = now + barIntervalMs; - drawStatusBar(); - } -} diff --git a/src/lua/lua_app.h b/src/lua/lua_app.h deleted file mode 100644 index 60ba1df..0000000 --- a/src/lua/lua_app.h +++ /dev/null @@ -1,142 +0,0 @@ -#pragma once - -extern "C" { -#include -} - -#include -#include - -#include "../ui/layout.h" - -class LuaApp { - public: - TFT_eSPI& tft; // accessed by the C binding shims in the .cpp - XPT2046_Touchscreen& touch; - // One screen at a time: a rebuild resets the arena, which is why the heap returns to - // the same shape after every one instead of fragmenting. - ui::Tree tree; - - LuaApp(TFT_eSPI& tft, XPT2046_Touchscreen& touch); - ~LuaApp(); - - // `hasBack` controls whether the status bar offers navigation to an earlier route. - // `arg` reaches the app as init()'s only parameter, or nil when there is none: states - // share no memory, so a string is the whole handoff between one app and the next. - bool load(const char* path, bool hasBack = false, const char* arg = nullptr); - void loop(); - - // What the status bar shows. Defaults to the app's directory name ("settings") until - // the app renames itself with sys.setAppName(). - const String& name() const { return appName; } - void setName(const String& name) { appName = name; } - - // Lets an app own the whole panel: no viewport, no status bar. The touch calibration - // needs the physical edges; almost nothing else should. - void setFullscreen(bool on); - - // Re-clips the app out of the status bar. Needed after every setRotation(), which - // leaves the previous viewport metrics behind. - void applyViewport(); - - // Brings the bar's next repaint forward. Not an invalidation: the bar still decides for - // itself what changed, this only says the answer is worth asking for before the next - // tick, so a rename or a rotation is not left sitting for most of a second. - void refreshStatusBar() { nextBarMs = 0; } - - enum class ExitAction { None, Back, Launch, Replace }; - - // Deferred navigation: bindings and bar touches run inside loop(), so the host handles - // the route change after Lua has returned and the old state can be closed safely. - void requestBack(); - void requestLaunch(const char* path, const char* arg, bool replace = false); - ExitAction takeExitAction(); - String takePendingLaunch(); - String takePendingArg(); - bool hasPendingArg() const { return pendingArgSet; } - bool running() const { return state != nullptr && !exitRequested; } - - // True once if the app died on an error, so the host - // can leave the message on screen long enough to read. - bool takeFailure(); - - // Set from Lua by sys.setTickInterval(); 0 stops the ticks. - void setTickInterval(uint32_t intervalMs); - - static constexpr uint32_t MIN_TICK_MS = 33; // no point ticking faster than a draw - static constexpr uint32_t MAX_TICK_MS = 3600000; // an hour - - // Panel geometry in its rotation-0 frame; calibration is stored in this space - // so rotating the UI never needs a recalibration. - static constexpr int16_t PANEL_W = 320; - static constexpr int16_t PANEL_H = 480; - - // App coordinates, so the status bar's rows are subtracted unless the app owns the panel. - void mapTouch(const TS_Point& p, int16_t& x, int16_t& y) const; - - static constexpr size_t READ_CAP = 64 * 1024; - - private: - lua_State* state = nullptr; - bool exitRequested = false; - bool failed = false; - ExitAction exitAction = ExitAction::None; - String pendingLaunch; - // Tracked apart from the string: an app may legitimately pass "", which is not the same - // as being launched with no argument at all. - String pendingArg; - bool pendingArgSet = false; - bool lastTouched = false; - bool ignoreRelease = false; - bool fullscreen = false; - bool hasBack = false; - bool backArmed = 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. 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; - uint32_t barIntervalMs = 0; - // The controller reports no position once the finger lifts, so on_touch_up and the - // tap alias replay the last point seen while it was down. - int16_t lastX = 0, lastY = 0; - // Last point reported to on_touch_move, which is not lastX/lastY: sub-threshold jitter - // must not accumulate into a move one pixel at a time. - int16_t movedX = 0, movedY = 0; - // Noise only. Gesture intent (tap vs drag) is the UI toolkit's threshold, not this one, - // because a painting app's drag starts at the first real pixel. - static constexpr int16_t MOVE_EPSILON_PX = 2; - - void closeState(); - void installLoader(const char* appDir); - void fireTouch(const char* name, int16_t x, int16_t y); - void loadStatusBar(); - void drawStatusBar(); - uint32_t tickIntervalMs = 0; - uint32_t nextTickMs = 0; - uint32_t nextDrawMs = 0; - - static constexpr uint32_t DRAW_INTERVAL_MS = 33; - // 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 ? 0 : barHeight; } - - // The back 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. - bool inBackButton() const { - return hasBack && lastY < 0 && lastY >= -barInset() && lastX >= 0 && lastX < barInset(); - } - - void requestExit(); - - void registerBindings(); - bool callGlobal(const char* name, int nargs = 0); - bool hasGlobal(const char* name); - void fail(const char* message); -}; diff --git a/src/lua/module_loader.cpp b/src/lua/module_loader.cpp deleted file mode 100644 index 9ca91df..0000000 --- a/src/lua/module_loader.cpp +++ /dev/null @@ -1,134 +0,0 @@ -// SD-backed `require`. Lua's stock loaders go through stdio, which cannot see the SD -// mount, so every path into the filesystem is replaced with one that reads through SD. - -#include - -#include "bindings.h" -#include "lua_app.h" - -// Streamed rather than slurped: holding a whole module as one String needs that many bytes -// contiguous, and once WiFi is up the largest free block is around 40KB even though far -// more than that is free. Lua only ever wants the next few bytes. -namespace { -struct ChunkReader { - File file; - char buf[512]; -}; - -const char* readChunk(lua_State*, void* ud, size_t* size) { - ChunkReader* reader = static_cast(ud); - int read = reader->file.read((uint8_t*)reader->buf, sizeof(reader->buf)); - *size = read > 0 ? (size_t)read : 0; - return read > 0 ? reader->buf : nullptr; -} -} // namespace - -// Whole file in one String, for callers that want the bytes themselves. Scripts go through -// loadScript(), which never materializes the file. -bool readScript(const char* path, String& out) { - // Probe first: a missed SD.open logs a VFS error, and the searcher misses by design. - if (!SD.exists(path)) return false; - File file = SD.open(path); - if (!file || file.isDirectory()) { - if (file) file.close(); - return false; - } - // readString() reports an allocation failure by silently returning a partial read, so the - // size is verified rather than trusted: a truncated script is a syntax error at a random - // offset, which says nothing about the real cause. - size_t size = file.size(); - out = file.readString(); - file.close(); - if (out.length() != size) { - Serial.printf("[lua] %s: short read, %u of %u bytes\n", path, out.length(), (unsigned)size); - return false; - } - return true; -} - -int loadScript(lua_State* L, const char* path) { - ChunkReader reader; - if (SD.exists(path)) reader.file = SD.open(path); - if (!reader.file || reader.file.isDirectory()) { - if (reader.file) reader.file.close(); - lua_pushfstring(L, "cannot open %s", path); - return LUA_ERRFILE; - } - String chunkname = String("@") + path; - int status = lua_load(L, readChunk, &reader, chunkname.c_str(), "t"); - reader.file.close(); - return status; -} - -static int l_loadfile(lua_State* L) { - if (loadScript(L, luaL_checkstring(L, 1)) == LUA_OK) return 1; - lua_pushnil(L); - lua_insert(L, -2); - return 2; -} - -static int l_dofile(lua_State* L) { - const char* path = luaL_checkstring(L, 1); - if (loadScript(L, path) != LUA_OK) return lua_error(L); - lua_call(L, 0, LUA_MULTRET); - return lua_gettop(L) - 1; -} - -// Resolves a module name against package.path, reporting every path tried the way -// the stock searcher does. -static int l_searcher(lua_State* L) { - String name = luaL_checkstring(L, 1); - name.replace('.', '/'); - - lua_getglobal(L, "package"); - lua_getfield(L, -1, "path"); - String templates = luaL_optstring(L, -1, ""); - lua_pop(L, 2); - - String tried; - int start = 0; - while (start <= (int)templates.length()) { - int end = templates.indexOf(';', start); - if (end < 0) end = templates.length(); - String candidate = templates.substring(start, end); - start = end + 1; - if (candidate.length() == 0) continue; - candidate.replace("?", name); - - if (SD.exists(candidate.c_str())) { - if (loadScript(L, candidate.c_str()) != LUA_OK) { - return luaL_error(L, "error loading module '%s' from '%s':\n\t%s", - luaL_checkstring(L, 1), candidate.c_str(), lua_tostring(L, -1)); - } - lua_pushstring(L, candidate.c_str()); - return 2; - } - tried += "\n\tno file '" + candidate + "'"; - } - lua_pushstring(L, tried.c_str()); - return 1; -} - -void LuaApp::installLoader(const char* appDir) { - lua_getglobal(state, "package"); - - String path = String(appDir) + "/?.lua;/lib/?.lua"; - lua_pushstring(state, path.c_str()); - lua_setfield(state, -2, "path"); - - // Keep the preload searcher, drop the C loaders: they can only report misleading - // errors about shared objects that never existed here. - lua_getfield(state, -1, "searchers"); - lua_pushcfunction(state, l_searcher); - lua_rawseti(state, -2, 2); - for (int i = 3; i <= 4; i++) { - lua_pushnil(state); - lua_rawseti(state, -2, i); - } - lua_pop(state, 2); - - lua_pushcfunction(state, l_loadfile); - lua_setglobal(state, "loadfile"); - lua_pushcfunction(state, l_dofile); - lua_setglobal(state, "dofile"); -} diff --git a/src/main.cpp b/src/main.cpp index b1e70e5..e3af618 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,92 +1,44 @@ +// 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 -#include #include -#include "lua/lua_app.h" +#include "host/lua_host.h" #include "net.h" #include "settings.h" -// SD card is on a separate bus from the display/touch (board schematic) static constexpr int SD_CS = 5; static constexpr int SD_SCK = 18; static constexpr int SD_MOSI = 23; static constexpr int SD_MISO = 19; static constexpr int TOUCH_CS = 33; -static const char* HOME = "/apps/Home/main.lua"; -static constexpr uint32_t ERROR_HOLD_MS = 5000; - -// Arduino's default 8KB loop stack is not enough once a TLS handshake runs inside a -// Lua binding: the first HTTPS download tripped the stack canary. Everything the VM -// calls runs on this task, so the headroom belongs here rather than in each caller. SET_LOOP_TASK_STACK_SIZE(16 * 1024); TFT_eSPI tft; SPIClass touchSpi(HSPI); SPIClass& sdSpi = SPI; XPT2046_Touchscreen touch(TOUCH_CS); -LuaApp app(tft, touch); +LuaHost host(tft, touch); -struct Route { - String path; - String arg; - bool hasArg = false; -}; +static bool halted = false; -static constexpr size_t MAX_HISTORY = 8; -bool halted = false; -Route currentRoute; -Route pendingRoute; -Route history[MAX_HISTORY]; -size_t historyDepth = 0; -LuaApp::ExitAction pendingAction = LuaApp::ExitAction::None; - -Route homeRoute() { - Route route; - route.path = HOME; - return route; -} - -void pushHistory(const Route& route) { - if (historyDepth == MAX_HISTORY) { - // Home remains the eventual floor; discard the oldest intermediate route. - for (size_t i = 1; i < MAX_HISTORY - 1; ++i) history[i] = history[i + 1]; - historyDepth--; - } - history[historyDepth++] = route; -} - -Route previousRoute() { - return historyDepth > 0 ? history[--historyDepth] : homeRoute(); -} - -// Last resort only: the UI lives in Lua, so this exists purely to explain why -// nothing else could run. -void fallbackScreen(const char* message) { +static void fallbackScreen(const char* message) { tft.resetViewport(); // no app, no status bar: this message owns the panel tft.setRotation(0); tft.fillScreen(TFT_WHITE); + tft.setTextSize(2); tft.setTextColor(TFT_RED, TFT_WHITE); tft.drawString(message, 10, 10); tft.setTextColor(TFT_BLACK, TFT_WHITE); - tft.drawString("expected /apps/Home/main.lua", 10, 30); + tft.drawString("expected /.lua/apps/Home/main.lua", 10, 34); Serial.printf("halted: %s\n", message); halted = true; } -void startApp(const Route& route) { - // 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 - tft.resetViewport(); // load() re-clips once the new app's bar reports its height - Serial.printf("launching %s free=%u largest=%u\n", route.path.c_str(), ESP.getFreeHeap(), - ESP.getMaxAllocHeap()); - const char* arg = route.hasArg ? route.arg.c_str() : nullptr; - if (app.load(route.path.c_str(), historyDepth > 0, arg)) return; - if (route.path == HOME && historyDepth == 0) fallbackScreen("home failed to start"); -} - void setup() { Serial.begin(115200); tft.begin(); @@ -100,51 +52,20 @@ void setup() { fallbackScreen("SD card mount failed"); return; } + settings.load(); // absent file keeps the built-in defaults net::begin(); - currentRoute = homeRoute(); - startApp(currentRoute); + if (!host.begin()) fallbackScreen("home failed to start"); } void loop() { - // Nothing here changes without a reset, so idle hard rather than spinning on it. if (halted) { delay(100); return; } net::loop(); - - if (app.running()) { - app.loop(); - if (!app.running()) { - pendingAction = app.takeExitAction(); - pendingRoute.path = app.takePendingLaunch(); - pendingRoute.hasArg = app.hasPendingArg(); - pendingRoute.arg = app.takePendingArg(); - } - } else { - // A failed child returns to its caller after leaving the message readable. Home has - // no caller, so its failure still reaches the last-resort screen in startApp(). - if (app.takeFailure()) { - delay(ERROR_HOLD_MS); - if (pendingAction == LuaApp::ExitAction::None) pendingAction = LuaApp::ExitAction::Back; - } - - if (pendingAction == LuaApp::ExitAction::Launch) { - pushHistory(currentRoute); - currentRoute = pendingRoute; - } else if (pendingAction == LuaApp::ExitAction::Replace) { - currentRoute = pendingRoute; - } else { - currentRoute = previousRoute(); - } - pendingAction = LuaApp::ExitAction::None; - pendingRoute = {}; - startApp(currentRoute); - } - - // Hands the core to the idle task instead of spinning between polls: touch reads are - // throttled to 3 ms and draws to 33 ms, so a millisecond of sleep costs nothing that - // can be seen or felt. + host.loop(); + // The launcher failing to start is the one error no app can recover from. + if (!host.running()) fallbackScreen("home failed to start"); delay(1); } diff --git a/src/ui/layout.h b/src/ui/layout.h deleted file mode 100644 index b1ac3e5..0000000 --- a/src/ui/layout.h +++ /dev/null @@ -1,421 +0,0 @@ -#pragma once - -// The widget tree: structure, block-flow layout, labels and style, over a flat node -// arena. Free of Arduino headers so test/ui_layout_test.cpp can exercise it on the host. -// Ported from the Lua toolkit's Component:measure/place, whose semantics the tests -// still describe. -// -// The tree is split across two arenas because the halves have different lifetimes. A -// Node holds what hit testing and repainting need forever: 16 bytes. A Spec holds what -// only measure() and place() read -- requested sizes, padding, gap, alignment -- and is -// dropped when the pass ends. Re-layout rebuilds from Lua rather than retaining ~20 -// bytes per node against a rotation nobody measures in milliseconds. - -#include -#include -#include -#include -#include - -namespace ui { - -constexpr uint16_t NONE = 0xFFFF; - -// Distinguishes "no constraint" from a real zero, which a plain int cannot: an auto-sized -// parent genuinely has no width to hand down, and a child asking for a fraction of it is -// an error rather than a zero-width silence. -constexpr int UNKNOWN = INT32_MIN; - -enum Type : uint8_t { BOX, TEXT, BUTTON, CUSTOM }; - -enum Flag : uint8_t { - ROW = 1 << 0, // main axis is horizontal - CAPTURE = 1 << 1, // swallows the taps its children missed - INTERACTIVE = 1 << 2, // has an on_press - DIRTY = 1 << 3, - PRESSED = 1 << 4, -}; - -enum SizeMode : uint8_t { AUTO, PX, FRACTION, FILL }; -enum Align : uint8_t { START, CENTER, END, BETWEEN }; - -// Which roles a style states for itself. Anything unset is answered by the nearest -// ancestor that does state it, so styling stays in one place and a node that names no -// colours costs no bytes at all. -enum StyleField : uint16_t { - S_FG = 1 << 0, - // The background a node offers its descendants to draw text on, which is not the same - // as the fill it paints: a dialog layer hands the lit palette down while painting - // nothing itself. What is physically behind a node is derived at paint time, never set. - S_BG = 1 << 1, - S_FILL = 1 << 9, - S_BORDER = 1 << 3, - S_FACE = 1 << 4, // button gradient, top and bottom - S_PRESSED = 1 << 5, // pressed gradient - S_PRESS_FG = 1 << 6, - S_RADIUS = 1 << 7, - S_SIZE = 1 << 8, - S_TEXT_ALIGN = 1 << 10, -}; - -// Colours are RGB565, matching the panel and gui.color(). -struct Style { - uint16_t fg = 0x0000; - uint16_t bg = 0xFFFF; - uint16_t fill = 0xFFFF; - uint16_t border = 0x0000; - uint16_t faceTop = 0xFFFF, faceBottom = 0xFFFF; - uint16_t pressTop = 0x0000, pressBottom = 0x0000; - uint16_t pressFg = 0xFFFF; - uint8_t radius = 6; - uint8_t size = 1; - Align textAlign = START; // within the node's own box, which a text node usually fills - uint16_t set = 0; -}; - -// FRACTION is per-mille rather than a float: 0.85 of 320 is 272 either way, and the -// firmware has no business rounding differently to the host. -struct Size { - SizeMode mode = AUTO; - int16_t value = 0; - - static Size make(SizeMode mode, int16_t value) { - Size size; - size.mode = mode; - size.value = value; - return size; - } - static Size px(int16_t v) { return make(PX, v); } - static Size fraction(int16_t permille) { return make(FRACTION, permille); } - static Size fill() { return make(FILL, 0); } -}; - -// Persistent. w/h hold the measured size between measure() and place(), and the final -// rect afterwards, because the two are never needed at once. -struct Node { - int16_t x = 0, y = 0, w = 0, h = 0; - uint16_t first = NONE, next = NONE, parent = NONE; - uint8_t type = BOX; - uint8_t flags = 0; -}; - -// Scratch. Lives only for the duration of a build plus its layout pass. -struct Spec { - Size w, h; - Size atX, atY; - bool absolute = false; - int16_t intrinsicW = 0, intrinsicH = 0; // content size of a leaf, e.g. a text run - uint8_t padT = 0, padR = 0, padB = 0, padL = 0; - uint8_t gap = 0; - Align align = START; - Align justify = START; - uint16_t last = NONE; // tail of the child list, so append is not a walk -}; - -// Resistive panels land a few pixels off, so a hit box is larger than what was painted. -constexpr int SLOP = 4; - -constexpr uint16_t NO_LABEL = 0xFFFF; - -class Tree { - public: - std::vector nodes; - std::vector specs; - const char* error = nullptr; - - void reset() { - nodes.clear(); - specs.clear(); - labelAt.clear(); - labels.clear(); - styles.clear(); - error = nullptr; - } - - uint16_t add(uint16_t parent, const Spec& spec, uint8_t type = BOX, uint8_t flags = 0) { - // A tree whose scratch has been dropped cannot be extended: its layout inputs are - // gone, so building again is a new screen by definition. Self-healing rather than - // advisory, because the alternative is a spec list that no longer indexes the nodes. - if (specs.size() != nodes.size()) reset(); - uint16_t id = static_cast(nodes.size()); - nodes.push_back(Node()); - specs.push_back(spec); - labelAt.push_back(NO_LABEL); - nodes[id].type = type; - nodes[id].flags = flags; - nodes[id].parent = parent; - if (parent != NONE) attach(parent, id); - return id; - } - - // Lua evaluates inner constructors first, so a child exists before the box that holds - // it. Adopting afterwards is what lets each spec table be garbage the moment its node - // is created, instead of a whole screen's worth of them living until the end of a build. - void attach(uint16_t parent, uint16_t child) { - nodes[child].parent = parent; - uint16_t tail = specs[parent].last; - if (tail == NONE) { - nodes[parent].first = child; - } else { - nodes[tail].next = child; - } - specs[parent].last = child; - } - - bool layout(uint16_t root, int x, int y, int w, int h) { - error = nullptr; - measure(root, w, h); - if (error) return false; - place(root, x, y, w, h); - return error == nullptr; - } - - // Deepest interactive node wins, so a tappable child beats its tappable parent. The - // list is singly linked, so "last match walking forward" stands in for "first match - // walking backward"; they name the same node. - uint16_t hit(uint16_t id, int px, int py) const { - const Node& n = nodes[id]; - if (px < n.x - SLOP || px >= n.x + n.w + SLOP) return NONE; - if (py < n.y - SLOP || py >= n.y + n.h + SLOP) return NONE; - - uint16_t found = NONE; - for (uint16_t c = n.first; c != NONE; c = nodes[c].next) { - uint16_t inner = hit(c, px, py); - if (inner != NONE) found = inner; - } - if (found != NONE) return found; - if (n.flags & CAPTURE) return id; - return (n.flags & INTERACTIVE) ? id : NONE; - } - - // Layout inputs are dead once place() has run. Callers drop them here rather than - // carrying ~20 bytes a node for the lifetime of a screen. - void dropScratch() { - specs.clear(); - specs.shrink_to_fit(); - } - - // Labels share one NUL-separated arena, so a text node costs two bytes plus its - // characters rather than a string object. - // - // ponytail: a longer label appends and abandons the old bytes. The clock repaints - // every second at a fixed width, which overwrites in place, so the arena only grows - // when a label genuinely gets longer. Compact on rebuild if some app proves otherwise. - void setLabel(uint16_t id, const char* text) { - size_t length = strlen(text); - uint16_t at = labelAt[id]; - if (at != NO_LABEL && strlen(&labels[at]) >= length) { - memcpy(&labels[at], text, length + 1); - return; - } - labelAt[id] = static_cast(labels.size()); - labels.insert(labels.end(), text, text + length + 1); - } - - const char* label(uint16_t id) const { - uint16_t at = labelAt[id]; - return at == NO_LABEL ? nullptr : &labels[at]; - } - - size_t footprint() const { - return nodes.size() * sizeof(Node) + labelAt.size() * sizeof(uint16_t) + labels.size() + - styles.size() * sizeof(styles[0]); - } - - // Styles are sparse because inheritance means almost every node states nothing: a - // screen's root carries the palette and a handful of nodes override one role. Ids are - // handed out in increasing order during a build, so appends keep the list sorted and - // lookup is a binary search. - Style& styleFor(uint16_t id) { - size_t low = 0, high = styles.size(); - while (low < high) { - size_t mid = (low + high) / 2; - if (styles[mid].first < id) { - low = mid + 1; - } else { - high = mid; - } - } - if (low < styles.size() && styles[low].first == id) return styles[low].second; - return styles.insert(styles.begin() + low, std::make_pair(id, Style()))->second; - } - - // The nearest style at or above `id` that states `field`, or a default one if nothing - // does. Returning the whole style lets a caller read the pair a role comes in. - const Style& inherited(uint16_t id, uint16_t field) const { - static const Style fallback; - for (uint16_t n = id; n != NONE; n = nodes[n].parent) { - const Style* style = styleOf(n); - if (style && (style->set & field)) return *style; - } - return fallback; - } - - const Style* styleOf(uint16_t id) const { - size_t low = 0, high = styles.size(); - while (low < high) { - size_t mid = (low + high) / 2; - if (styles[mid].first < id) { - low = mid + 1; - } else { - high = mid; - } - } - return (low < styles.size() && styles[low].first == id) ? &styles[low].second : nullptr; - } - - private: - std::vector labelAt; - std::vector labels; - std::vector > styles; - - void fail(const char* message) { - if (!error) error = message; - } - - int resolve(Size size, int span) { - switch (size.mode) { - case AUTO: - return UNKNOWN; - case PX: - return size.value; - case FILL: - if (span == UNKNOWN) { - fail("fill inside an auto-sized parent"); - return 0; - } - return span; - case FRACTION: - if (span == UNKNOWN) { - fail("fraction inside an auto-sized parent"); - return 0; - } - return span * size.value / 1000; - } - return UNKNOWN; - } - - void measure(uint16_t id, int availW, int availH) { - // By value, and re-index after recursion: measuring a child can push nodes, and a - // vector that grows moves every reference taken before it. - const Spec s = specs[id]; - const bool row = (nodes[id].flags & ROW) != 0; - int w = resolve(s.w, availW); - int h = resolve(s.h, availH); - - int innerW = w != UNKNOWN ? w - s.padL - s.padR - : (availW != UNKNOWN ? availW - s.padL - s.padR : UNKNOWN); - // Height is only handed down when this node was given one. A parent sized by its - // content cannot tell a child what fraction of it to take. - int innerH = h != UNKNOWN ? h - s.padT - s.padB : UNKNOWN; - - int main = 0, cross = 0, count = 0; - for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next) { - measure(c, innerW, innerH); - // Absolutely placed children are measured, because they still need a size, but they - // take no part in the flow their siblings share. The Lua original counted them here - // and excluded them in place(); nothing depended on the disagreement. - if (specs[c].absolute) continue; - const Node& child = nodes[c]; - if (count) main += s.gap; - if (row) { - main += child.w; - if (child.h > cross) cross = child.h; - } else { - main += child.h; - if (child.w > cross) cross = child.w; - } - count++; - } - if (count == 0) { - main = row ? s.intrinsicW : s.intrinsicH; - cross = row ? s.intrinsicH : s.intrinsicW; - } - - Node& self = nodes[id]; - int along = main + (row ? s.padL + s.padR : s.padT + s.padB); - int across = cross + (row ? s.padT + s.padB : s.padL + s.padR); - if (row) { - self.w = static_cast(w != UNKNOWN ? w : along); - self.h = static_cast(h != UNKNOWN ? h : across); - } else { - self.w = static_cast(w != UNKNOWN ? w : across); - self.h = static_cast(h != UNKNOWN ? h : along); - } - } - - void place(uint16_t id, int x, int y, int w, int h) { - const Spec s = specs[id]; - bool row = (nodes[id].flags & ROW) != 0; - { - Node& self = nodes[id]; - self.x = static_cast(x); - self.y = static_cast(y); - self.w = static_cast(w); - self.h = static_cast(h); - self.flags |= DIRTY; - } - - int cx = x + s.padL, cy = y + s.padT; - int cw = w - s.padL - s.padR, ch = h - s.padT - s.padB; - - // Main-axis distribution, CSS justify-content minus the modes nothing asks for. - // Absolutely placed children take no part in the flow. - int flowing = 0, used = 0; - for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next) { - if (specs[c].absolute) continue; - flowing++; - used += row ? nodes[c].w : nodes[c].h; - } - if (flowing > 1) used += (flowing - 1) * s.gap; - int slack = (row ? cw : ch) - used; - if (slack < 0) slack = 0; - - int offset = 0, spread = 0; - if (s.justify == END) { - offset = slack; - } else if (s.justify == CENTER) { - offset = slack / 2; - } else if (s.justify == BETWEEN && flowing > 1) { - spread = slack / (flowing - 1); - } - - for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next) { - const Spec cs = specs[c]; - int childW = nodes[c].w, childH = nodes[c].h; - // Cross axis fills the parent unless the child asked for a size, like CSS blocks. - if (row) { - if (cs.h.mode == AUTO) childH = ch; - } else { - if (cs.w.mode == AUTO) childW = cw; - } - - int px, py; - if (cs.absolute) { - px = cx + resolve(cs.atX, cw); - py = cy + resolve(cs.atY, ch); - } else if (row) { - px = cx + offset; - py = cy; - if (s.align == CENTER) { - py += (ch - childH) / 2; - } else if (s.align == END) { - py += ch - childH; - } - offset += childW + s.gap + spread; - } else { - px = cx; - py = cy + offset; - if (s.align == CENTER) { - px += (cw - childW) / 2; - } else if (s.align == END) { - px += cw - childW; - } - offset += childH + s.gap + spread; - } - place(c, px, py, childW, childH); - } - } -}; - -} // namespace ui diff --git a/src/ui/paint.h b/src/ui/paint.h deleted file mode 100644 index 2af2964..0000000 --- a/src/ui/paint.h +++ /dev/null @@ -1,184 +0,0 @@ -#pragma once - -// Painting the node tree. Needs the panel, so unlike layout.h this is not host-testable; -// keep anything that can be decided without pixels on the other side of that line. -// -// Repainting follows the Lua original: a dirty node paints itself and dirties its -// children, because a parent's fill lands on top of whatever they drew. Nothing tracks -// sub-regions -- a widget that wants to repaint part of itself is a CUSTOM node and does -// it through the gui bindings, which is what the on-screen keyboard already does. - -#include - -#include "../gfx/round_rect.h" -#include "layout.h" - -namespace ui { - -constexpr int MAX_SPAN = 480; // longest panel edge, so one row buffer covers any shape - -// One primitive draws the whole surface: fill (solid or vertical gradient) and border -// derive from the same distance field, so they cannot disagree at the corners the way -// two separate rounded-rect algorithms did. The panel has no alpha, so edge pixels are -// blended against `surface`, the colour of whatever sits underneath. -inline void drawRoundRect(TFT_eSPI& tft, int x, int y, int w, int h, float radius, - uint16_t surface, bool hasFill, uint16_t top, uint16_t bottom, - bool hasBorder, uint16_t border) { - if (w <= 0 || h <= 0 || w > MAX_SPAN) return; - float halfWidth = w * 0.5f, halfHeight = h * 0.5f; - if (radius < 0.0f) radius = 0.0f; - float limit = (w < h ? w : h) / 2.0f; - if (radius > limit) radius = limit; - - static uint16_t span[MAX_SPAN]; - - // pushImage sends the buffer verbatim, but the panel wants each colour big-endian. - bool previousSwap = tft.getSwapBytes(); - tft.setSwapBytes(true); - for (int row = 0; row < h; row++) { - uint16_t fill = hasFill ? gfx::lerp565(top, bottom, row, h - 1) : 0; - float py = row + 0.5f - halfHeight; - for (int column = 0; column < w; column++) { - float distance = - gfx::roundRectDistance(column + 0.5f - halfWidth, py, halfWidth, halfHeight, radius); - float outer = gfx::coverage(distance); - // The border is the ring between the shape and the same shape inset by its width. - float inner = hasBorder ? gfx::coverage(distance + 1.0f) : outer; - uint16_t pixel = surface; - if (hasFill) pixel = gfx::blend565(pixel, fill, inner); - if (hasBorder) pixel = gfx::blend565(pixel, border, outer - inner); - span[column] = pixel; - } - tft.pushImage(x, y + row, w, 1, span); - } - tft.setSwapBytes(previousSwap); -} - -// A CUSTOM node paints through Lua, so the walk needs a way back. One dispatcher for the -// whole tree rather than a reference per node: the Lua side already keys its painters by -// node id and can look one up faster than the registry can hand it over. -typedef void (*CustomPainter)(void* context, uint16_t id, int x, int y, int w, int h); - -class Painter { - public: - Painter(TFT_eSPI& tft, Tree& tree) : tft(tft), tree(tree) {} - - CustomPainter custom = nullptr; - void* context = nullptr; - - void draw(uint16_t id) { - Node& n = tree.nodes[id]; - if (n.flags & DIRTY) { - paint(id); - n.flags &= ~DIRTY; - for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next) { - tree.nodes[c].flags |= DIRTY; - } - } - for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next) draw(c); - } - - private: - TFT_eSPI& tft; - Tree& tree; - - // What a node sits on, which is not what it fills. Derived rather than stored, because - // a node cannot be told what is behind it: a dialog layer paints nothing, so its card - // blends into the dimmed content two levels up, not into the lit palette the layer - // hands its children. Nothing filling means the panel, cleared to the root's colour. - uint16_t surfaceOf(uint16_t id) const { - for (uint16_t n = tree.nodes[id].parent; n != NONE; n = tree.nodes[n].parent) { - const Style* style = tree.styleOf(n); - if (style && (style->set & S_FILL)) return style->fill; - } - uint16_t root = id; - while (tree.nodes[root].parent != NONE) root = tree.nodes[root].parent; - return tree.inherited(root, S_BG).bg; - } - - void paint(uint16_t id) { - const Node& n = tree.nodes[id]; - switch (n.type) { - case BUTTON: - paintButton(id); - break; - case TEXT: - paintText(id); - break; - case CUSTOM: - // Cleared first, because a custom painter draws what it wants and nothing knows - // what it drew last time. The keyboard's number page is narrower than its letter - // page, and without this the wider row's outer keys survive the repaint. - tft.fillRect(n.x, n.y, n.w, n.h, tree.inherited(id, S_BG).bg); - if (custom) custom(context, id, n.x, n.y, n.w, n.h); - break; - default: - paintBox(id); - break; - } - } - - // A bordered box paints its own background as a rounded rect. Filling a square first - // would leave corners outside the border, which is invisible against a matching - // surface and obvious against any other. - void paintBox(uint16_t id) { - const Node& n = tree.nodes[id]; - const Style* own = tree.styleOf(id); - bool hasBorder = own && (own->set & S_BORDER); - bool hasFill = own && (own->set & S_FILL); - if (!hasBorder) { - if (hasFill) tft.fillRect(n.x, n.y, n.w, n.h, own->fill); - return; - } - uint16_t fill = hasFill ? own->fill : tree.inherited(id, S_BG).bg; - drawRoundRect(tft, n.x, n.y, n.w, n.h, tree.inherited(id, S_RADIUS).radius, surfaceOf(id), - true, fill, fill, true, own->border); - } - - void paintButton(uint16_t id) { - const Node& n = tree.nodes[id]; - bool pressed = (n.flags & PRESSED) != 0; - const Style& face = tree.inherited(id, pressed ? S_PRESSED : S_FACE); - uint16_t top = pressed ? face.pressTop : face.faceTop; - uint16_t bottom = pressed ? face.pressBottom : face.faceBottom; - drawRoundRect(tft, n.x, n.y, n.w, n.h, tree.inherited(id, S_RADIUS).radius, surfaceOf(id), - true, top, bottom, true, tree.inherited(id, S_FG).fg); - } - - // Glyphs over a button are transparent: an opaque fill is one flat colour, which - // matches only the single row of the gradient it was taken from. The face is repainted - // whenever it changes, so the label has nothing to erase. - void paintText(uint16_t id) { - const Node& n = tree.nodes[id]; - const char* label = tree.label(id); - if (!label) return; - - uint16_t parent = n.parent; - bool onButton = parent != NONE && tree.nodes[parent].type == BUTTON; - bool pressed = onButton && (tree.nodes[parent].flags & PRESSED); - - tft.setTextSize(tree.inherited(id, S_SIZE).size); - // A text node usually fills its parent's width, so alignment is inside its own box. - int x = n.x; - Align align = tree.inherited(id, S_TEXT_ALIGN).textAlign; - if (align == CENTER) { - x += (n.w - static_cast(tft.textWidth(label))) / 2; - } else if (align == END) { - x += n.w - static_cast(tft.textWidth(label)); - } - if (pressed) { - tft.setTextColor(tree.inherited(id, S_PRESS_FG).pressFg); - } else if (onButton) { - tft.setTextColor(tree.inherited(id, S_FG).fg); - } else { - // The whole box is cleared, not just the glyphs: a label replaced by a shorter one - // would otherwise leave the tail of the old text standing next to the new. - uint16_t bg = tree.inherited(id, S_BG).bg; - tft.fillRect(n.x, n.y, n.w, n.h, bg); - tft.setTextColor(tree.inherited(id, S_FG).fg, bg); - } - tft.drawString(label, x, n.y); - } -}; - -} // namespace ui