feat(ui): layout toolkit, Lua launcher and touch press events
Apps describe nesting instead of coordinates: /lib/ui.lua borrows CSS block flow, the box model and auto sizing, and owns hit testing, press capture and the pressed repaint. The runtime gains require backed by the SD card, on_touch_down/on_touch_up, text metrics and rounded gradient fills, so the launcher becomes an ordinary Lua app and the firmware keeps only a fallback screen for an unreadable card.
This commit is contained in:
+219
-19
@@ -67,6 +67,53 @@ static int l_gui_drawLine(lua_State* L) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int l_gui_fillRoundRect(lua_State* L) {
|
||||
app(L)->tft.fillRoundRect(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3),
|
||||
luaL_checkinteger(L, 4), luaL_checkinteger(L, 5), luaL_checkinteger(L, 6));
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int l_gui_drawRoundRect(lua_State* L) {
|
||||
app(L)->tft.drawRoundRect(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3),
|
||||
luaL_checkinteger(L, 4), luaL_checkinteger(L, 5), luaL_checkinteger(L, 6));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Interpolating in RGB565 keeps this a pure integer loop; a rounded corner just
|
||||
// insets the span, so gradient buttons need no separate corner fill.
|
||||
static int l_gui_fillRectGradient(lua_State* L) {
|
||||
int x = luaL_checkinteger(L, 1), y = luaL_checkinteger(L, 2);
|
||||
int w = luaL_checkinteger(L, 3), h = luaL_checkinteger(L, 4);
|
||||
int radius = luaL_checkinteger(L, 5);
|
||||
uint16_t top = luaL_checkinteger(L, 6), bottom = luaL_checkinteger(L, 7);
|
||||
if (w <= 0 || h <= 0) return 0;
|
||||
|
||||
int r1 = top >> 11, g1 = (top >> 5) & 0x3F, b1 = top & 0x1F;
|
||||
int r2 = bottom >> 11, g2 = (bottom >> 5) & 0x3F, b2 = bottom & 0x1F;
|
||||
radius = constrain(radius, 0, min(w, h) / 2);
|
||||
|
||||
for (int row = 0; row < h; row++) {
|
||||
uint16_t color = ((r1 + (r2 - r1) * row / (h - 1)) << 11) |
|
||||
((g1 + (g2 - g1) * row / (h - 1)) << 5) |
|
||||
(b1 + (b2 - b1) * row / (h - 1));
|
||||
int inset = 0;
|
||||
int edge = row < radius ? radius - row : (row >= h - radius ? row - (h - radius - 1) : 0);
|
||||
if (edge > 0) inset = radius - (int)sqrtf((float)(radius * radius - edge * edge));
|
||||
app(L)->tft.drawFastHLine(x + inset, y + row, w - 2 * inset, color);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int l_gui_fontHeight(lua_State* L) {
|
||||
lua_pushinteger(L, app(L)->tft.fontHeight());
|
||||
return 1;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -145,6 +192,12 @@ static int l_input_getRawTouch(lua_State* L) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
static int l_sys_launch(lua_State* L) {
|
||||
app(L)->requestLaunch(luaL_checkstring(L, 1));
|
||||
app(L)->requestExit();
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int l_sys_getRotation(lua_State* L) {
|
||||
lua_pushinteger(L, settings.rotation);
|
||||
return 1;
|
||||
@@ -210,6 +263,25 @@ static int l_fs_exists(lua_State* L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int l_fs_listDirs(lua_State* L) {
|
||||
File dir = SD.open(luaL_checkstring(L, 1));
|
||||
lua_newtable(L);
|
||||
if (!dir || !dir.isDirectory()) {
|
||||
if (dir) dir.close();
|
||||
return 1;
|
||||
}
|
||||
int i = 1;
|
||||
for (File entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
|
||||
if (entry.isDirectory() && entry.name()[0] != '.') {
|
||||
lua_pushstring(L, entry.name());
|
||||
lua_rawseti(L, -2, i++);
|
||||
}
|
||||
entry.close();
|
||||
}
|
||||
dir.close();
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int l_fs_listFiles(lua_State* L) {
|
||||
File dir = SD.open(luaL_checkstring(L, 1));
|
||||
lua_newtable(L);
|
||||
@@ -236,6 +308,108 @@ static int l_log(lua_State* L) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- module loading ----
|
||||
|
||||
// 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.
|
||||
static 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 f = SD.open(path);
|
||||
if (!f || f.isDirectory()) {
|
||||
if (f) f.close();
|
||||
return false;
|
||||
}
|
||||
out = f.readString();
|
||||
f.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
static int loadScript(lua_State* L, const char* path) {
|
||||
String source;
|
||||
if (!readScript(path, source)) {
|
||||
lua_pushfstring(L, "cannot open %s", path);
|
||||
return LUA_ERRFILE;
|
||||
}
|
||||
String chunkname = String("@") + path;
|
||||
return luaL_loadbuffer(L, source.c_str(), source.length(), chunkname.c_str());
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
String source;
|
||||
if (readScript(candidate.c_str(), source)) {
|
||||
String chunkname = String("@") + candidate;
|
||||
if (luaL_loadbuffer(L, source.c_str(), source.length(), chunkname.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");
|
||||
}
|
||||
|
||||
LuaApp::LuaApp(TFT_eSPI& tft, XPT2046_Touchscreen& touch) : tft(tft), touch(touch) {}
|
||||
|
||||
LuaApp::~LuaApp() {
|
||||
@@ -257,6 +431,14 @@ bool LuaApp::callGlobal(const char* name, int nargs) {
|
||||
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);
|
||||
}
|
||||
|
||||
void LuaApp::fail(const char* message) {
|
||||
Serial.printf("[lua] error: %s\n", message ? message : "(unknown)");
|
||||
tft.fillScreen(TFT_WHITE);
|
||||
@@ -269,21 +451,19 @@ void LuaApp::fail(const char* message) {
|
||||
bool LuaApp::load(const char* path) {
|
||||
self = this;
|
||||
closeState();
|
||||
// The launcher tap may still be down; require a release before the first edge.
|
||||
// The launcher tap may still be down; swallow that gesture's release.
|
||||
lastTouched = true;
|
||||
ignoreRelease = true;
|
||||
state = luaL_newstate();
|
||||
if (!state) return false;
|
||||
luaL_openlibs(state);
|
||||
registerBindings();
|
||||
|
||||
File f = SD.open(path);
|
||||
if (!f) {
|
||||
fail("cannot open script");
|
||||
return false;
|
||||
}
|
||||
String source = f.readString();
|
||||
f.close();
|
||||
if (luaL_loadbuffer(state, source.c_str(), source.length(), path) != LUA_OK) {
|
||||
String appDir = path;
|
||||
int slash = appDir.lastIndexOf('/');
|
||||
installLoader(slash > 0 ? appDir.substring(0, slash).c_str() : "/");
|
||||
|
||||
if (loadScript(state, path) != LUA_OK) {
|
||||
fail(lua_tostring(state, -1));
|
||||
return false;
|
||||
}
|
||||
@@ -306,19 +486,26 @@ void LuaApp::registerBindings() {
|
||||
{"millis", l_sys_millis}, {"exit", l_sys_exit},
|
||||
{"setCalibration", l_sys_setCalibration},
|
||||
{"getRotation", l_sys_getRotation}, {"setRotation", l_sys_setRotation},
|
||||
{"launch", l_sys_launch},
|
||||
{nullptr, nullptr}};
|
||||
static const luaL_Reg guiLib[] = {
|
||||
{"width", l_gui_width}, {"height", l_gui_height}, {"clear", l_gui_clear},
|
||||
{"fillRect", l_gui_fillRect}, {"drawRect", l_gui_drawRect}, {"fillCircle", l_gui_fillCircle},
|
||||
{"drawLine", l_gui_drawLine}, {"drawText", l_gui_drawText}, {"setRotation", l_gui_setRotation},
|
||||
{"color", l_gui_color}, {nullptr, nullptr}};
|
||||
{"color", l_gui_color},
|
||||
{"fillRoundRect", l_gui_fillRoundRect},
|
||||
{"drawRoundRect", l_gui_drawRoundRect},
|
||||
{"fillRectGradient", l_gui_fillRectGradient},
|
||||
{"fontHeight", l_gui_fontHeight},
|
||||
{"textWidth", l_gui_textWidth},
|
||||
{nullptr, nullptr}};
|
||||
static const luaL_Reg inputLib[] = {{"getTouch", l_input_getTouch},
|
||||
{"getRawTouch", l_input_getRawTouch},
|
||||
{"touched", l_input_touched},
|
||||
{nullptr, nullptr}};
|
||||
static const luaL_Reg fsLib[] = {{"readFile", l_fs_readFile}, {"writeFile", l_fs_writeFile},
|
||||
{"exists", l_fs_exists}, {"listFiles", l_fs_listFiles},
|
||||
{nullptr, nullptr}};
|
||||
{"listDirs", l_fs_listDirs}, {nullptr, nullptr}};
|
||||
static const luaL_Reg logLib[] = {{"info", l_log}, {nullptr, nullptr}};
|
||||
|
||||
luaL_newlib(state, sysLib);
|
||||
@@ -337,6 +524,14 @@ void LuaApp::registerBindings() {
|
||||
// here would free the VM that is still executing.
|
||||
void LuaApp::requestExit() { exitRequested = true; }
|
||||
|
||||
void LuaApp::requestLaunch(const char* path) { pendingLaunch = path; }
|
||||
|
||||
String LuaApp::takePendingLaunch() {
|
||||
String path = pendingLaunch;
|
||||
pendingLaunch = "";
|
||||
return path;
|
||||
}
|
||||
|
||||
void LuaApp::closeState() {
|
||||
if (state) {
|
||||
lua_close(state);
|
||||
@@ -351,14 +546,19 @@ void LuaApp::loop() {
|
||||
uint32_t now = millis();
|
||||
|
||||
bool touched = touch.touched();
|
||||
if (touched && !lastTouched && hasGlobal("on_touch")) {
|
||||
lua_getglobal(state, "on_touch");
|
||||
// The finger can lift between polls; calling with an empty stack corrupts it.
|
||||
if (touchPoint(state)) {
|
||||
callGlobal("on_touch", 2);
|
||||
} else {
|
||||
lua_pop(state, 1);
|
||||
}
|
||||
if (touched) {
|
||||
TS_Point p = touch.getPoint();
|
||||
mapTouch(tft, p, lastX, lastY);
|
||||
}
|
||||
if (touched && !lastTouched) {
|
||||
fireTouch("on_touch_down", 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;
|
||||
|
||||
@@ -17,6 +17,10 @@ class LuaApp {
|
||||
|
||||
bool load(const char* path);
|
||||
void loop();
|
||||
|
||||
// Set by sys.launch(); the host loop reads it once the app has torn down.
|
||||
void requestLaunch(const char* path);
|
||||
String takePendingLaunch();
|
||||
bool running() const { return state != nullptr && !exitRequested; }
|
||||
void requestExit();
|
||||
|
||||
@@ -32,9 +36,16 @@ class LuaApp {
|
||||
private:
|
||||
lua_State* state = nullptr;
|
||||
bool exitRequested = false;
|
||||
String pendingLaunch;
|
||||
bool lastTouched = false;
|
||||
bool ignoreRelease = false;
|
||||
// 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;
|
||||
|
||||
void closeState();
|
||||
void installLoader(const char* appDir);
|
||||
void fireTouch(const char* name, int16_t x, int16_t y);
|
||||
uint32_t tickIntervalMs = 0;
|
||||
uint32_t nextTickMs = 0;
|
||||
uint32_t nextDrawMs = 0;
|
||||
|
||||
+26
-63
@@ -13,56 +13,35 @@ static constexpr int SD_MOSI = 23;
|
||||
static constexpr int SD_MISO = 19;
|
||||
static constexpr int TOUCH_CS = 33;
|
||||
|
||||
static const char* LAUNCHER = "/apps/launcher/main.lua";
|
||||
|
||||
TFT_eSPI tft;
|
||||
SPIClass touchSpi(HSPI);
|
||||
SPIClass& sdSpi = SPI;
|
||||
XPT2046_Touchscreen touch(TOUCH_CS);
|
||||
LuaApp app(tft, touch);
|
||||
|
||||
bool sdOk = false;
|
||||
String appPath;
|
||||
bool halted = false;
|
||||
String nextApp;
|
||||
|
||||
void drawLauncher() {
|
||||
// Last resort only: the UI lives in Lua, so this exists purely to explain why
|
||||
// nothing else could run.
|
||||
void fallbackScreen(const char* message) {
|
||||
tft.setRotation(0);
|
||||
tft.fillScreen(TFT_WHITE);
|
||||
tft.setTextColor(TFT_RED, TFT_WHITE);
|
||||
tft.drawString(message, 10, 10);
|
||||
tft.setTextColor(TFT_BLACK, TFT_WHITE);
|
||||
tft.drawString("esp32-lcd - tap an app", 10, 10);
|
||||
|
||||
File dir = SD.open("/apps");
|
||||
int y = 40;
|
||||
while (File entry = dir.openNextFile()) {
|
||||
if (entry.isDirectory() && entry.name()[0] != '.') {
|
||||
tft.drawRect(10, y - 4, tft.width() - 20, 28, TFT_BLACK);
|
||||
tft.drawString(entry.name(), 18, y);
|
||||
y += 36;
|
||||
}
|
||||
entry.close();
|
||||
}
|
||||
dir.close();
|
||||
tft.drawString("expected /apps/launcher/main.lua", 10, 30);
|
||||
Serial.printf("halted: %s\n", message);
|
||||
halted = true;
|
||||
}
|
||||
|
||||
// ponytail: first N rows of 36px = first N app dirs, in SD order. A scrollable
|
||||
// menu widget can come later; this is a launcher, not an OS.
|
||||
void launcherTouch() {
|
||||
if (!touch.touched()) return;
|
||||
TS_Point p = touch.getPoint();
|
||||
int16_t tx, ty;
|
||||
LuaApp::mapTouch(tft, p, tx, ty);
|
||||
int idx = (ty - 40) / 36;
|
||||
if (idx < 0) return;
|
||||
|
||||
File dir = SD.open("/apps");
|
||||
int i = 0;
|
||||
while (File entry = dir.openNextFile()) {
|
||||
if (entry.isDirectory() && entry.name()[0] != '.') {
|
||||
if (i++ == idx) {
|
||||
appPath = String("/apps/") + entry.name() + "/main.lua";
|
||||
entry.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
entry.close();
|
||||
}
|
||||
dir.close();
|
||||
void startApp(const String& path) {
|
||||
tft.setRotation(settings.rotationIndex()); // apps may have rotated the frame
|
||||
Serial.printf("launching %s\n", path.c_str());
|
||||
if (app.load(path.c_str())) return;
|
||||
if (path == LAUNCHER) fallbackScreen("launcher failed to start");
|
||||
}
|
||||
|
||||
void setup() {
|
||||
@@ -75,40 +54,24 @@ void setup() {
|
||||
|
||||
sdSpi.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
|
||||
if (!SD.begin(SD_CS, sdSpi)) {
|
||||
tft.setTextColor(TFT_RED, TFT_WHITE);
|
||||
tft.drawString("SD card mount failed", 10, 10);
|
||||
Serial.println("SD mount failed");
|
||||
fallbackScreen("SD card mount failed");
|
||||
return;
|
||||
}
|
||||
sdOk = true;
|
||||
settings.load(); // absent file keeps the built-in touch defaults
|
||||
tft.setRotation(settings.rotationIndex());
|
||||
drawLauncher();
|
||||
Serial.println("launcher ready");
|
||||
settings.load(); // absent file keeps the built-in defaults
|
||||
startApp(LAUNCHER);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (!sdOk) return; // SD failed to mount; error already drawn
|
||||
if (halted) return;
|
||||
|
||||
if (app.running()) {
|
||||
app.loop();
|
||||
if (!app.running()) {
|
||||
appPath = "";
|
||||
tft.setRotation(settings.rotationIndex()); // apps may have rotated the frame
|
||||
drawLauncher();
|
||||
Serial.println("launcher ready");
|
||||
}
|
||||
if (!app.running()) nextApp = app.takePendingLaunch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (appPath.length() > 0) {
|
||||
String path = appPath;
|
||||
appPath = "";
|
||||
Serial.printf("launching %s\n", path.c_str());
|
||||
app.load(path.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
launcherTouch();
|
||||
String path = nextApp.length() > 0 ? nextApp : String(LAUNCHER);
|
||||
nextApp = "";
|
||||
startApp(path);
|
||||
delay(10);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user