Commit Graph

43 Commits

Author SHA1 Message Date
evan f7c5cc09ba feat(ui)!: move the widget tree into C++
A node was a Lua table of ~625 bytes, of which 21 keys pushed it over a
power-of-two hash boundary and eight were style copies inheritance had
splattered down from its parent. A 400 node screen cost ~250 KB and could not
coexist with wifi's buffers.

The tree now lives in src/ui/layout.h as a 16 byte struct in a flat arena, and
splits by lifetime: Node holds what hit testing and repainting need forever,
Spec holds what only measure/place read and is dropped when layout ends. Style
is sparse and resolved by walking parents, so a node naming no colours costs
nothing. Re-layout rebuilds from Lua rather than retaining the inputs.

    401 nodes:  8218 B steady, 21050 B peak
           Lua: ~250000 B steady

sdcard/lib/ui.lua stays the toolkit and keeps every constructor signature, but
returns integer handles: 627 lines to 374. Composition, the palette and custom
painters are still Lua on the SD card; only primitives now need a reflash.

BREAKING CHANGE: ui constructors return handles, not tables. Use
ui.setText(id, text) and keep per-node app data in a table keyed by id.
2026-08-02 18:48:48 -04:00
evan 932d41aeeb revert(ui): remove scrollable box
Keep raw touch-move events for direct gesture consumers such as Paint. Toolkit screens remain frame-sized to avoid redraw and heap costs.
2026-08-02 17:44:39 -04:00
evan 7c0928d84e feat(ui): add drag gestures and a scrollable box
The firmware now fires on_touch_move with a 2px noise threshold; gesture
intent is the toolkit's 8px slop, since a painting app's stroke starts at
the first real pixel. Past the slop, Screen:move hands the drag to the
nearest scroll ancestor and cancels the widget under the finger.

ui.scroll leans on the app viewport for clipping, so it must reach both
panel edges. Paint and Scroll demo both halves of the gesture split.
2026-08-02 16:59:42 -04:00
evan b51d9c3c4a fix(ui): stabilize partial press redraws 2026-08-02 16:18:11 -04:00
evan 6b6280b72a feat(ui): add reusable on-screen keyboard 2026-08-02 16:07:10 -04:00
evan b2f58118e1 revert(gui): return text rendering to built-in Font 1
Remove OpenFontRender, embedded Meslo assets, custom-font APIs, and their runtime allocation overhead. Keep the taller status layout and compact card grid, with used RAM, aligned faded WiFi bars, and tighter telemetry rows.
2026-08-02 15:26:40 -04:00
evan 9a88d03d23 fix(gui): survive font rendering under low heap
Cache font heights, release FreeType glyph state between apps, and catch renderer allocation failures before they cross Lua and abort the device. Individual low-memory draws degrade to allocation-free Font 1. Show disconnected WiFi as three faded zero-signal bars without expanding the icon footprint.
2026-08-02 15:14:08 -04:00
evan a8602bbd56 refactor(statusbar): simplify memory telemetry
Show used memory in kB without a capacity or icon, and place it immediately left of the right-aligned WiFi indicator.
2026-08-02 14:58:47 -04:00
evan 1cb52997a9 feat(gui): render user-selectable TTF fonts
Embed a Latin-1 Meslo LG S default and use OpenFontRender for anti-aliased text with an explicit pixel size on each draw and measurement call. Apps may replace the one retained face with an SD-backed TTF and restore Meslo with nil. Refresh UI sizing, status telemetry, Settings typography, tests, documentation, and font license notices.
2026-08-02 14:54:49 -04:00
evan 497fab35d9 feat(ui): enlarge the status bar and card grids
Use a two-row 44px status bar with a full-height back button, centered app name, WiFi beside the clock, and free/total RAM below. Expand grids to three portrait and four landscape columns with consistent card text and left-to-right partial rows.
2026-08-02 13:45:00 -04:00
evan 0ca1fd9842 feat(lua): add app navigation history
Make sys.launch push the current path and argument, sys.replace switch without pushing, and sys.back restore the previous route. The status-bar chevron uses the same back action, failed child apps return to their caller, and history is capped at eight routes. Remove the redundant Settings back card.
2026-08-02 13:30:12 -04:00
evan 01dfc4b458 refactor: capitalize app directory names
The directory name is what the status bar and the launcher cards display, so Home,
Hello and Settings read as titles without a lookup table. Updates the firmware's home
path, the launcher's self-exclusion and the host tests that load the settings app.

Also brings the bar's next repaint forward when an app renames itself or the frame
rotates. The bar still decides what changed; this only stops the answer waiting most of
a second for the next tick.
2026-08-02 13:20:19 -04:00
evan 021b94fca2 feat(lua): pass an argument to init() and let apps name themselves
sys.launch(path, arg) carries a string to the next app's init(arg), nil when there is
none. States share no memory, so one string is the whole handoff; anything structured
travels as a Lua literal the receiver loads. This is what a screen split across apps
needs to say "collect a password for this network".

sys.setAppName() retitles the status bar, defaulting to the directory name as before. A
setter rather than a declared constant, so one app can retitle per screen. The bar needs
no new invalidation path for it -- the name joins rotation and theme in the cache key --
and clips a name wide enough to reach the memory slot.
2026-08-02 12:02:09 -04:00
evan 118e04e340 fix(settings): paint the scan and keyboard notices before the work that blocks
on_tick runs before draw in the same pass, so a screen built for a blocking call was
never painted until the call returned -- on a panel ui.screen() had already cleared.
Scanning looked like a hang on a blank screen. The notice is now painted by the tap
itself and centered on both axes.
2026-08-02 11:20:48 -04:00
evan db7f72d30f fix(lua): stream scripts off the SD card instead of buffering them whole
Reading a module into one String needs that many bytes contiguous. Once WiFi is up the
largest free block drops to ~40KB, so loading /lib/ui.lua failed -- and readString()
reports allocation failure by silently returning a partial read, which reached Lua as a
syntax error at a random offset. Every app launch was broken while connected.

loadScript() now feeds lua_load() through a 512 byte buffer, so no module needs a
contiguous allocation. readScript(), still used by fs.readFile, verifies the length it
got against the file size rather than trusting it. The launch log carries free and
largest-block so the next heap question is answerable without a rebuild.
2026-08-02 11:20:48 -04:00
evan 8f890625f7 docs: add agent guidelines 2026-08-02 10:49:05 -04:00
evan 917f65ae2f feat(ui): give the dark theme purple button faces 2026-08-02 10:49:05 -04:00
evan b5146b8642 refactor(lua): give the status bar its own dirty tracking and split settings out of sys
The bar repainted itself whole every second. It now compares each field against what
it last painted, adds seconds and a memory percentage, and keys the cache on
gui.getRotation() and ui.themeName so rotation and theme changes still repaint it.
Invalidation lives entirely in Lua; the firmware's push flag and gfx/statusbar.h are gone.

Bindings follow getName/setName/isName, persisted preferences move from sys to a settings
table, and gui.setRotation takes degrees like settings does. A bar that dies mid-run now
keeps its rows reserved rather than silently resizing the running app.
2026-08-02 10:49:01 -04:00
evan b9b620ad2b feat(ui): add a home button to the status bar, rename launcher to home
Leaving an app was the app's own responsibility, so one that shipped without
an exit could only be escaped with a reset. The bar now paints a back button
into its leading square and the firmware treats that rect as home, acting on
release so a press sliding into the app cancels. The app it returns to is
/apps/home, which is what it is to the user.

Card grids in home and settings centre left to right as a unit.
2026-08-01 23:13:20 -04:00
evan 6bbe192081 fix(ui): draw button labels without an opaque text background
gui.drawText painted a flat rectangle behind every glyph, so a label on a
gradient card sat in a patch matching only the one gradient row its colour
came from. Omitting the background colour now draws transparent glyphs, and
buttons pass none: the face is repainted above them on every change, so the
label has nothing to erase.
2026-08-01 22:58:19 -04:00
evan 114c8c5f72 feat(ui): add persistent status bar and card grid launcher
The bar is host-owned chrome: the firmware clips apps into a viewport below
it, so no app can paint over it, while /lib/statusbar.lua owns the height,
repaint interval and painting. gui.fullscreen() lets touch calibration take
the physical panel back.

Launcher and settings become grids of square cards (3 across landscape, 2
portrait) via the new gui.setTextSize, ui.label and ui.cardSide. The one
function on the `app` table moves to sys.setTickInterval, alongside the new
sys.appName the bar needs.
2026-08-01 22:50:19 -04:00
evan f5e44885db chore: rename 2026-08-01 22:08:54 -04:00
evan fea0e9c291 fix(settings): reduce keyboard memory usage 2026-08-01 21:43:17 -04:00
evan a3bda03056 fix(input): correct rotated touch mapping 2026-08-01 20:04:48 -04:00
evan dbabb03413 fix(input): align touch axes with panel 2026-08-01 19:58:30 -04:00
evan 0bd953d596 fix: turn the backlight on
TFT_eSPI drives the backlight pin only when TFT_BL and TFT_BACKLIGHT_ON are both
defined. Only the first was, so GPIO27 was never even configured as an output: the
panel initialised, took every pixel it was sent and stayed dark.

The emulator cannot catch this. It models the framebuffer, not the lamp, so a board
with no backlight and a board with a perfect one render identically.
2026-08-01 19:11:11 -04:00
evan 7925eace57 chore: rename the project to slate32
esp32-lcd named the substrate rather than the thing, and both halves would age: the
chip is swappable and the panel technology is incidental. What the project actually is
is a Lua app platform that happens to run on a cheap touchscreen, so the name is now a
model number rather than a parts list. slate32 is also unclaimed, where slate alone
collides with several well known projects.

Board identifiers stay as they were -- the e32r40t QEMU machine, the esp32-32e build
env and the test skill name all refer to real hardware, which did not get renamed.
2026-08-01 18:58:16 -04:00
evan e530331613 feat(ui): dim the content behind a dialog
The panel has no alpha and there is no framebuffer to blend against -- 307KB against
276KB free -- so a scrim cannot be composited. It can be computed instead: the palette
is re-derived from seeds mixed toward black, and the content behind a dialog is
repainted in those colors, which is the result a black scrim would have produced. The
tree was already the source of truth, so dimming is a palette swap rather than a
readback. A node marked dimmed carries it to everything it contains, and ui.confirm
opts its card back out.

Doing that exposed a latent bug in bordered boxes. They filled a square rect and then
drew a rounded border over it, so the corners the border does not cover showed the
fill. That was invisible while everything behind a card was the same color as the card,
and obvious the moment the background dimmed. Separating the two roles fixes it: bg is
what a node fills, surface is what sits underneath, anti-aliased edges blend into
surface, and a box that paints its own rounded background suppresses the square fill.
2026-08-01 18:54:19 -04:00
evan f7c86ded80 feat(ui): dialogs as ordinary nodes, with capture, border and fill
A dialog is a component the app includes when its state says so, placed absolutely so
it covers the flow instead of joining it, and dismissed by rebuilding without it. No
layer stack, no module state, no lifecycle: "on top" already means "later in the child
list", which the draw walk gives for free, and nothing needs to survive a rebuild
because the tree is derived from state rather than mutated beside it.

The alternative was a ui.push/ui.pop stack of roots. It would have been the only
imperative thing in an otherwise declarative program, and needed rules to reconcile two
ways for something to reach the screen -- the rule that a rebuild must only replace the
base being the one that would eventually be forgotten.

Three primitives were missing and are now here. capture makes a component swallow the
taps its children missed, without which the hit walk falls back to earlier siblings and
a dialog can be tapped through. border draws a box as a rounded rect. fill names a size
a fraction cannot express, since any number >= 1 is read as pixels, so w = 1.0 asked
for one pixel.

The root now measures as "fill" rather than auto. It is placed at the full panel rect
already, so its own children could not resolve a fraction of the one component whose
size is never in doubt.

Settings asks before forgetting a network, which is the first caller.
2026-08-01 18:43:35 -04:00
evan c8d927e2cb perf: yield the core at the end of the main loop
The loop spun at full clock between polls, so the CPU was pegged no matter what an app
was doing. delay(1) hands the core to the idle task, which lets the WiFi and lwIP tasks
run promptly and lets real silicon clock-gate in WAITI. Touch reads are throttled to
3 ms by the driver and draws to 33 ms, so nothing observable slows down.

The halted path now idles at 100 ms rather than spinning on a state that cannot change
without a reset.

Not measurable in the emulator: QEMU does not model WAITI, so host CPU stays at 105%
either way. The gain is on hardware.
2026-08-01 18:19:22 -04:00
evan 5a3aabf220 feat(ui): setText on text nodes
Assigning .label repainted nothing until the caller also remembered to invalidate, and
forgetting produced a screen that silently stopped updating with no error to follow.
setText makes the pair atomic and skips the repaint when the text is unchanged, so an
app can push a value on every tick without comparing first.
2026-08-01 18:09:08 -04:00
evan f36292734a docs: wifi.status() and the tick interval are no longer deviations
Both firmwares now return {state, ssid, ip, rssi} from wifi.status() and set the tick
interval through app.setTickInterval(), so the drift list is down to the filesystem
gaps and timers.
2026-08-01 18:02:32 -04:00
evan 8045faddb4 refactor: replace the TICK_MS global with app.setTickInterval()
A magic global that the runtime reads once at startup could not be changed later, gave
no feedback when misspelled, and was a second spelling of a mechanism the sibling
firmware already had. app.setTickInterval(ms) clamps to 33..3600000, takes 0 to stop,
and errors when on_tick() is not defined -- by the time init() runs the chunk body has
finished, so a missing callback is a typo rather than a race.

Also drops the code comments pointing at the other repo. Where the two APIs agree or
differ belongs in docs/lua-api-parity.md; a comment beside a constant explaining that
another firmware picked the same number is noise a reader here cannot act on.
2026-08-01 17:58:11 -04:00
evan d338dfe3e8 refactor: rename the app entry point to init() and require it
crosspoint-reader calls it init() and requires it; this called it setup() and treated
it as optional. Same concept, two spellings, so an app could not move between the two
firmwares for no reason worth defending. init() wins because it is also the stricter
contract: a misspelled entry point is now an error instead of an app that starts,
draws nothing, and explains nothing.

Requiring it exposed that error screens were unreadable. fail() painted the message
and the host relaunched the launcher over it on the very next frame, so every Lua
error was serial-only -- which would have made "Missing init()" useless to anyone
holding the device rather than a console.
2026-08-01 17:52:32 -04:00
evan 6f2d618b8d feat: http bindings matching crosspoint-reader, plus generated Lua stubs
The http table copies crosspoint-reader's signatures exactly -- get/head/delete/post/
patch returning (body|nil, status), download taking maxBytes/expectedSize/sha256, the
same 50000 byte body cap and the same -1 for a request that never left the device --
so a script that talks to a server runs on either firmware. docs/lua-api-parity.md
records that, and every other place the two APIs agree, differ for a reason, or differ
because nobody noticed.

Two crosspoint behaviours are deliberately not copied. It reinterprets a string in
argument 2 of a GET as a request body, which turns a mistyped headers table into a
silent protocol error. More seriously it calls setInsecure() on every request, so TLS
is encrypted but unauthenticated on the very path a firmware update would use; this
verifies against the root bundle already sitting in the framework, and the emulator
confirms expired.badssl.com is refused while a wrong sha256 deletes the file.

Downloading exposed two failures worth naming. A 2KB read buffer on the stack tripped
the loop task's canary because a TLS handshake had already spent it, and the
hand-rolled read loop spun forever on a stream that stopped producing -- HTTPClient's
own writeToStream handles both, so the loop is gone and the loop task gets 16KB.

scripts/gen_lua_stubs.py generates stubs/esp32lcd.lua in the same LuaLS format
crosspoint uses, reading annotations off the luaL_Reg tables so a module's docs sit
with its registration. make test runs --check, which crosspoint's copy never wired up.
2026-08-01 17:33:47 -04:00
evan 2cdb7b3702 feat: timezone setting and main-axis justify in the ui toolkit
The launcher clock read UTC and sat next to the title because the toolkit could only
stack children from the start of an axis. justify adds the CSS main-axis modes that
had a caller -- start, end, center, between -- so a header keeps its title left and
its clock right without any app doing arithmetic.

Timezones are stored as POSIX TZ rules rather than offsets, so newlib applies DST
changeovers and os.date() in Lua reports local time with no binding of its own.
/lib/timezones.lua is only the picker list: a zone missing from it still works if its
rule is written into settings, the same split themes already use.

settings_calibration.lua now finds rows by label. Adding the timezone row shifted
every hardcoded y coordinate in it, which is the failure that had been predicted and
would have silently retargeted taps at the wrong control.
2026-08-01 17:03:55 -04:00
evan 2b0355a148 feat: reclaim the spiffs partition, sync the clock, show it on the launcher
Nothing in this firmware ever mounted internal flash storage: apps, lib, settings and
themes all live on the SD card. The Arduino default table still reserved 1408K for
spiffs, so a custom table hands that to the OTA slots instead, taking each from 1280K
to 1984K. The 64K tail the slots cannot use goes to coredump, which otherwise logs a
missing-partition error on every boot. Changing the table shifts app0, so the next
flash must be a full one.

The ESP32 has no battery-backed clock, so time is 1970 on every power-up. net.cpp
seeds it from the build timestamp -- the device cannot predate its own firmware, and
certificate validity checks need a plausible clock before SNTP can answer -- then
starts SNTP on each join and latches a synced flag from the notification callback.
sntp_get_sync_status() only reports COMPLETED briefly before resetting for the next
cycle, so polling it would make the clock flap.

sys.clockSynced() is the only new binding a clock UI needs; Lua's os.date() already
reads the same system clock. The launcher shows it, in UTC for now.
2026-08-01 16:51:52 -04:00
evan 393ce3c0ed refactor: split the Lua runtime, share test stubs, add a Makefile
lua_app.cpp had grown to 701 lines holding every binding, the module loader and the
app lifecycle, so new bindings landed wherever the cursor was. Each Lua table now has
its own file under bindings/, and the app is recovered from the lua_State's extra
space instead of a file-static, so a second state cannot reach the wrong app.

Three tests each redeclared the binding surface, which broke twice this session when
a binding changed; test/fake_device.lua is now the single stub. `make test` runs all
four suites and pins Lua 5.4, matching the vendored interpreter rather than the 5.2
the tests had silently been using.
2026-08-01 11:17:42 -04:00
evan 36953bb140 fix(gui): anti-aliased rounded rects from one distance field
Corners were drawn by two disagreeing algorithms: a hand-rolled per-row inset for
the gradient fill and TFT_eSPI's Bresenham arc for the border, so they missed each
other by a pixel and left a halo. The inset was also wrong, truncating the sqrt and
ignoring pixel centres, which over-cut the top row by 2px into a visible chamfer.

gui.roundRect now derives fill and border from the same signed distance, blending
edge pixels by coverage, and replaces fillRoundRect, drawRoundRect and
fillRectGradient. The geometry moved to src/gfx/round_rect.h so round_rect_test.cpp
can check the arc on the host, since only the eye ever checked the old one.
2026-08-01 11:04:35 -04:00
evan 0a27062a70 feat(ui): themes derived from three seed colors
Every app re-declared the same black/white/accent triple, so a palette now lives in
/lib/theme.lua and ui.lua derives muted, contrast, gradients and radius from it.
Deriving rather than listing means a new component costs no theme keys, and a theme
cannot pick text that is unreadable on its own accent.

Components take the palette by inheritance, so the three apps name no colors at all;
ui.theme stays for drawing outside the tree. Settings cycles the installed themes and
stores only the name.
2026-08-01 10:04:52 -04:00
evan eec9b692c6 feat(wifi): scan, connect and persist networks from the settings app
Adds a wifi binding over the Arduino API and a settings flow that scans, picks
the strongest AP per SSID, takes a password from an on-screen keyboard, and
reports connection state. Credentials join /settings.lua and reconnect at boot.

Settings are now written through a temp file and rename, and strings are
Lua-escaped, so a password cannot corrupt the file the firmware parses at boot.
2026-08-01 06:29:38 -04:00
evan 4e5796fd51 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.
2026-07-31 22:27:34 -04:00
evan ad396d3f01 feat: Lua app firmware for the E32R40T display module
Boots to a launcher that lists /apps/<name>/main.lua on the SD card and runs
the selected app in a vendored Lua 5.4 with gui, input, fs, sys and log
bindings. Settings persist as a Lua table in /settings.lua, covering touch
calibration and screen rotation, with a settings app to edit both. Rotation is
applied after mapping raw touch into the panel's rotation-0 frame, so turning
the UI never invalidates a calibration.
2026-07-31 21:28:11 -04:00