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 <lua/providers.h> and #pragma once then silently skipped it.
This commit is contained in:
+1
-1
Submodule lib/esp32-lua-api updated: 95fa512047...d53f9b77cd
@@ -0,0 +1,269 @@
|
||||
#include "lua_host.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "../settings.h"
|
||||
|
||||
extern "C" {
|
||||
#include <lauxlib.h>
|
||||
#include <lua.h>
|
||||
}
|
||||
|
||||
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<lua_Integer>(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<esp32lua::TimerId> 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<int32_t>(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();
|
||||
}
|
||||
@@ -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 <TFT_eSPI.h>
|
||||
#include <XPT2046_Touchscreen.h>
|
||||
#include <lua/runtime.h>
|
||||
|
||||
#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;
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
#pragma once
|
||||
|
||||
// The firmware half of the shared Lua platform: hardware behind the interfaces in
|
||||
// <lua/providers.h>, and nothing else. Everything above these -- bindings, argument
|
||||
// checking, the node tree, app loading, navigation -- lives in lib/esp32-lua-api.
|
||||
|
||||
#include <TFT_eSPI.h>
|
||||
#include <XPT2046_Touchscreen.h>
|
||||
#include <lua/providers.h>
|
||||
|
||||
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<std::string>& names) const override;
|
||||
esp32lua::Status listFiles(const std::string& path, std::vector<std::string>& 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<esp32lua::HttpHeader>& 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<esp32lua::WifiNetwork>& 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<esp32lua::BleDevice>&) 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<esp32lua::TimerId>& due);
|
||||
|
||||
private:
|
||||
struct Slot {
|
||||
esp32lua::TimerId id = 0;
|
||||
uint32_t dueMs = 0;
|
||||
int32_t intervalMs = 0;
|
||||
bool repeating = false;
|
||||
};
|
||||
Slot slots[SLOTS];
|
||||
};
|
||||
|
||||
} // namespace slate
|
||||
@@ -0,0 +1,193 @@
|
||||
#include <SD.h>
|
||||
|
||||
#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<uint8_t*>(out), maxBytes);
|
||||
}
|
||||
|
||||
private:
|
||||
File file;
|
||||
};
|
||||
|
||||
Status listEntries(const std::string& path, bool directories, std::vector<std::string>& 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<int32_t>(file.size());
|
||||
file.close();
|
||||
return Status::success();
|
||||
}
|
||||
|
||||
Status Fs::listDirs(const std::string& path, std::vector<std::string>& names) const {
|
||||
return listEntries(path, true, names);
|
||||
}
|
||||
|
||||
Status Fs::listFiles(const std::string& path, std::vector<std::string>& 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<size_t>(maxBytes)) {
|
||||
file.close();
|
||||
return Status::failure("file exceeds maxBytes");
|
||||
}
|
||||
content.resize(size);
|
||||
const size_t read = size ? file.read(reinterpret_cast<uint8_t*>(&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<char>(c));
|
||||
if (static_cast<int32_t>(line.size()) > maxBytes) {
|
||||
file.close();
|
||||
return Status::failure("line exceeds maxBytes");
|
||||
}
|
||||
}
|
||||
nextOffset = static_cast<int32_t>(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<const uint8_t*>(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
|
||||
@@ -0,0 +1,172 @@
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
|
||||
#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<uint8_t>(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<float>(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<uint16_t>(*top) : 0;
|
||||
const uint16_t fillBottom = bottom ? static_cast<uint16_t>(*bottom) : fillTop;
|
||||
paintRoundRect(tft, x, y, w, h, static_cast<float>(radius), static_cast<uint16_t>(background), top != nullptr,
|
||||
fillTop, fillBottom, border != nullptr, border ? static_cast<uint16_t>(*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
|
||||
@@ -0,0 +1,214 @@
|
||||
#include <HTTPClient.h>
|
||||
#include <SD.h>
|
||||
#include <WiFi.h>
|
||||
#include <WiFiClient.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <mbedtls/sha256.h>
|
||||
|
||||
#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<unsigned char>(a[at])) != tolower(static_cast<unsigned char>(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<esp32lua::HttpHeader>& 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<uint8_t*>(const_cast<char*>(
|
||||
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<int32_t>(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<esp32lua::WifiNetwork>& 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
|
||||
@@ -0,0 +1,112 @@
|
||||
#include <Arduino.h>
|
||||
#include <esp_heap_caps.h>
|
||||
|
||||
#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<int32_t>(::millis()); }
|
||||
|
||||
esp32lua::MemoryInfo Sys::memory() const {
|
||||
esp32lua::MemoryInfo info;
|
||||
info.freeBytes = static_cast<int32_t>(ESP.getFreeHeap());
|
||||
info.totalBytes = static_cast<int32_t>(ESP.getHeapSize());
|
||||
info.largestFreeBlock = static_cast<int32_t>(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<esp32lua::TimerId>& 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<int32_t>(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
|
||||
@@ -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 <lauxlib.h>
|
||||
#include <lua.h>
|
||||
}
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
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);
|
||||
@@ -1,91 +0,0 @@
|
||||
// SD card access. Paths are absolute and rooted at the card.
|
||||
|
||||
#include <SD.h>
|
||||
|
||||
#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<const uint8_t*>(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");
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
// Drawing primitives. Colours are RGB565 integers throughout.
|
||||
|
||||
#include <TFT_eSPI.h>
|
||||
|
||||
#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");
|
||||
}
|
||||
@@ -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 <HTTPClient.h>
|
||||
#include <SD.h>
|
||||
#include <WiFiClient.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <mbedtls/sha256.h>
|
||||
|
||||
#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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 <TFT_eSPI.h>
|
||||
|
||||
#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<size_t>(id) < tree(L).nodes.size(), index,
|
||||
"no such node");
|
||||
return static_cast<uint16_t>(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<int16_t>(value * 1000.0 + 0.5))
|
||||
: ui::Size::px(static_cast<int16_t>(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<int>(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<uint8_t>(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<uint8_t>(readInt(L, pad, "t"));
|
||||
spec.padR = static_cast<uint8_t>(readInt(L, pad, "r"));
|
||||
spec.padB = static_cast<uint8_t>(readInt(L, pad, "b"));
|
||||
spec.padL = static_cast<uint8_t>(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<uint8_t>(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<int16_t>(app(L)->tft.textWidth(label));
|
||||
spec.intrinsicH = static_cast<int16_t>(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<lua_Integer>(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<uint16_t>(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<uint16_t>(lua_tointeger(L, -2));
|
||||
style.*(pair.bottom) = static_cast<uint16_t>(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<uint8_t>(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<uint8_t>(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<lua_State*>(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<lua_Integer>(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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 <WiFi.h>
|
||||
|
||||
#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");
|
||||
}
|
||||
@@ -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 <algorithm>
|
||||
|
||||
#include "../settings.h"
|
||||
#include "bindings.h"
|
||||
|
||||
extern "C" {
|
||||
#include <lauxlib.h>
|
||||
#include <lualib.h>
|
||||
}
|
||||
|
||||
// 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<LuaApp**>(lua_getextraspace(L)); }
|
||||
|
||||
void bindApp(lua_State* L, LuaApp* owner) { *static_cast<LuaApp**>(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();
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
extern "C" {
|
||||
#include <lua.h>
|
||||
}
|
||||
|
||||
#include <TFT_eSPI.h>
|
||||
#include <XPT2046_Touchscreen.h>
|
||||
|
||||
#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);
|
||||
};
|
||||
@@ -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 <SD.h>
|
||||
|
||||
#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<ChunkReader*>(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");
|
||||
}
|
||||
+14
-93
@@ -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 <SD.h>
|
||||
#include <SPI.h>
|
||||
#include <TFT_eSPI.h>
|
||||
#include <WiFi.h>
|
||||
#include <XPT2046_Touchscreen.h>
|
||||
|
||||
#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);
|
||||
}
|
||||
|
||||
-421
@@ -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 <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
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<Node> nodes;
|
||||
std::vector<Spec> 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<uint16_t>(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<uint16_t>(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<uint16_t> labelAt;
|
||||
std::vector<char> labels;
|
||||
std::vector<std::pair<uint16_t, Style> > 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<int16_t>(w != UNKNOWN ? w : along);
|
||||
self.h = static_cast<int16_t>(h != UNKNOWN ? h : across);
|
||||
} else {
|
||||
self.w = static_cast<int16_t>(w != UNKNOWN ? w : across);
|
||||
self.h = static_cast<int16_t>(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<int16_t>(x);
|
||||
self.y = static_cast<int16_t>(y);
|
||||
self.w = static_cast<int16_t>(w);
|
||||
self.h = static_cast<int16_t>(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
|
||||
-184
@@ -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 <TFT_eSPI.h>
|
||||
|
||||
#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<int>(tft.textWidth(label))) / 2;
|
||||
} else if (align == END) {
|
||||
x += n.w - static_cast<int>(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
|
||||
Reference in New Issue
Block a user