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:
2026-07-31 22:27:34 -04:00
parent ad396d3f01
commit 4e5796fd51
10 changed files with 789 additions and 148 deletions
+26 -63
View File
@@ -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);
}