Compare commits

16 Commits

Author SHA1 Message Date
evan a09bcfaf06 feat(ble): replace blocking scan with advertisement observation
The one-shot scan kept only the top six devices by RSSI and dropped the
advertisement payload, so a distant beacon lost its slot to nearby phones
and its data was unreachable -- wrong on every axis for reading sensors
that broadcast in their adverts.

Replace it with a continuous observer: observe(filter)/observed()/
unobserve()/isObserving(). BleObservation carries the raw advertisement
bytes for Lua to parse, and BleFilter keeps only adverts matching a
service-data UUID or manufacturer id.
2026-08-06 23:03:05 -04:00
evan eac4084f80 fix(ui): pad the toast card so its text clears the edges 2026-08-06 21:41:57 -04:00
evan 94d298dd45 feat(ui): add ui.toast, a message that slides up from the bottom edge
The card sits in a scroll container of its own height over an empty strip of
the same, so the slide is a setScroll() delta on the laid-out tree rather than
a rebuild a frame, and the container scissors the part not yet arrived.
2026-08-06 21:32:03 -04:00
evan 2046938d32 feat(ui): tree-owned scroll gestures and geometry hit-testing
Drag, flick and tap-vs-scroll now live on the scrollX/scrollY flag in
ui.lua, so any scroll box pans with no app code. tree.hit returns the
deepest node by geometry and dispatch bubbles to the nearest handler,
dropping the now-unused CAPTURE flag. keyboard moves in as ui.keyboard,
and embed compiles nested lib dirs to dotted module names.
2026-08-06 17:07:56 -04:00
evan e5ed980294 feat: scrolling 2026-08-06 11:16:15 -04:00
evan 26f3fdb6e6 feat(runtime)!: sys.startApp replaces routing, history and app identity
The runtime kept a back stack, a launcher fallback, an app id and a title
because a teardown destroys the Lua that would otherwise hold them. Only
the first of those is true: everything about where an app came from can
ride in the arguments, and the arguments are the one value that has to
outlive the VM.

So the runtime now does four things -- close the state, load a path, hand
the next state its arguments, defer the swap to a batch boundary -- and
sys.startApp(path, args) is the whole of navigation. Routing, history,
titles and data directories move to the Lua file a firmware boots, where
they can differ per product without a flag on Runtime.

Arguments cross as JSON, encoded while the sending state still holds the
table, so a function or a cycle raises at the call rather than stranding
a launch. start(args) receives the decoded table, or nil at boot, which
is how the entry file knows to open its own launcher.

Removes launch, replace, back, canGoBack, getAppID, getAppTitle,
setAppTitle and getAppDataPath, along with the home and data fields.
LANDSCAPE.md goes with them: it recorded a divergence from firmwares that
have since migrated.
2026-08-05 17:04:35 -04:00
evan 772618ef89 feat(json): JSON for apps through vendored lua-cjson
lua-cjson decodes straight onto the Lua stack, so a response costs its
text plus the table it becomes rather than a document in between, and it
brings the encode half that a C tokenizer would have left to write here.

It is a module rather than a global: a global namespace is a contract a
firmware implements, and nothing about this needs a provider. Registering
into package.preload also puts it ahead of the SD-card searcher, so an
implementation cannot be shadowed, and an app that never requires it
never pays for the module.

Depth is capped at 32 through the module's own knobs rather than by
patching the vendored source. Decoding recurses on the C stack and
upstream defaults to 1000, which assumes a server rather than a FreeRTOS
task.
2026-08-05 16:41:51 -04:00
evan b9f7c9347c refactor(api)!: declare callbacks as classes an app composes
The runtime has always called fields on the table main.lua returns, but
@lua-global declared them as loose functions, so the stubs type-checked
something that does not exist and read as "define a global".

Callbacks are now @lua-app blocks that generate a class: App for the core
contract, TouchHandlers and ButtonHandlers beside the namespaces they belong
to. An app composes what it implements:

  ---@class PaintApp : App, TouchHandlers

Names follow the rest of the surface: onTouchDown rather than on_touch_down,
with the field names the runtime looks up renamed to match. @lua-field carries
the plain fields (home, data) that were prose in a preamble before.
2026-08-05 10:42:01 -04:00
evan 75b3a2c490 refactor(api)!: one namespace per feature, screen split out
Namespaces were shared across features: `settings` was written by core, the
panel and touch, and `input` by touch and buttons. That made "does this
firmware implement the whole feature?" a question no pointer could answer.

Each namespace now belongs to exactly one feature or to core, so a feature is
a provider pointer and the compiler validates completeness:

  gui, node       -> screen, tree, under the screen feature
  settings        -> screen (rotation, theme), sys (timezone),
                     touch (calibration)
  input           -> touch, buttons

Runtime::open() no longer requires a GuiProvider; a firmware without one runs
with no screen/tree globals and reports sys.hasFeature("screen") false.
Rotation is one value again: GuiProvider::setRotation applies and persists, so
an app rotating the panel transiently puts the old value back itself.
2026-08-05 10:26:38 -04:00
evan 445a9b2b8f fix(ui): collect after a rebuild so the heap is in a known state
A build allocates a spec table per node and drops them all at once, so an
app that scans WiFi right after a screen change met whatever the incremental
GC had got around to. Costs a few ms on a screen change; recovers ~24 KB.
2026-08-04 21:30:25 -04:00
evan 4b89b27947 feat(ui): size a grid to the app's box, not the panel
Chrome takes the top of the panel before an app builds anything, and layout
has not run yet when it does, so ui.frame() reports what the mount left it.
2026-08-04 21:15:21 -04:00
evan 25b576a3d9 refactor(gui): drop setFullscreen now that chrome is a Lua node
Fullscreen was the firmware surrendering a strip it clipped apps out of.
The strip is a sibling node now, so an app that wants the panel is chrome
choosing not to build itself.
2026-08-04 20:58:56 -04:00
evan 2fc3abc487 feat(sys): expose canGoBack for the chrome that offers the control 2026-08-04 20:55:16 -04:00
evan 3f26c4ff10 feat(ui): one mounted tree instead of a screen object per build
Chrome and the app now share a tree, so a screen is no longer something an
app constructs and holds: ui.mount() takes the function that builds the
whole thing and ui.rebuild() runs it again. Building a node after layout
is refused rather than silently resetting the arena under the panel.
2026-08-04 20:53:15 -04:00
evan e3153f57c5 feat(runtime): hand the whole app contract to /.lua/main.lua
The firmware knew four paths and called four globals, so the card could
not change its own layout or put anything around an app. It now loads one
file, and the table that file returns owns the rest: start() mounts the
route, home and data name the tree, and every callback is a field on it
rather than a global the app and its chrome would have to share.
2026-08-04 20:51:25 -04:00
evan e85adfa757 feat(settings): persist the theme through the settings binding
The palette lives in Lua, so C++ stores the name only and ui.setTheme()
writes through here before rebuilding and repainting.
2026-08-04 20:44:08 -04:00
47 changed files with 4921 additions and 1319 deletions
+19 -2
View File
@@ -4,11 +4,28 @@ This is a clean contract for repositories under the same owner's control. Choose
API without preserving old names, signatures, or behavior; consumers migrate to the contract. API without preserving old names, signatures, or behavior; consumers migrate to the contract.
Every firmware implements all declarations under `lua/api/core/`. Optional hardware contracts Every firmware implements all declarations under `lua/api/core/`. Optional hardware contracts
live under `lua/api/features/`; `sys.hasFeature(name)` guarantees the complete matching contract. live under `lua/api/features/`, as one file or one directory per feature; `sys.hasFeature(name)`
guarantees the complete matching contract. Every namespace belongs to exactly one feature or to
core, so a feature is a provider pointer rather than a claim to validate: a panel is the `screen`
feature (`screen` and `tree`, including the saved rotation and theme), registered only when the
firmware supplies a `GuiProvider`, and calibration is `touch.setCalibration()`.
The runtime owns what survives a teardown and nothing else: `sys.startApp(path, args)` closes the
`lua_State`, loads a path, and hands the next one its arguments as JSON. Routing, history, titles
and data directories are that Lua file's, because a back stack that cannot outlive the VM is not a
back stack, and everything else about where an app came from can. Encoding happens in the state
that still holds the table, so unencodable arguments raise at the call rather than stranding a
launch.
A global namespace is a provider contract a firmware implements; anything this library provides
itself is a module instead, so `require` and globals divide by who supplies the code. Those live in
`native/src/bindings/lib/` and register into `package.preload`, which puts them ahead of the SD-card
searcher so nothing shadows an implementation. Pure-Lua modules stay in `lua/lib/`, where shadowing
is allowed because a module there is self-contained: it belongs in `lua/lib/` only if replacing it
can break nothing but itself.
Lua-language sources stay under `lua/`; C/C++ and the vendored interpreter stay under `native/`. Lua-language sources stay under `lua/`; C/C++ and the vendored interpreter stay under `native/`.
Apps are fully trusted; keep permissions and sandboxing out of scope. Apps are fully trusted; keep permissions and sandboxing out of scope.
Firmware commits dirty display content and owns panel refresh policy. Binding annotations under Firmware commits dirty display content and owns panel refresh policy. Binding annotations under
`native/src/bindings/` generate matching `lua/api/` files; regenerate instead of editing files `native/src/bindings/` generate matching `lua/api/` files; regenerate instead of editing files
marked generated. Each `@lua-module`/`@lua-augment` directive sits immediately above the marked generated. Callbacks are fields on the table an app returns, so a `@lua-app` block generates
a class an app composes (`---@class PaintApp : App, TouchHandlers`) rather than global functions. Each `@lua-module`/`@lua-augment` directive sits immediately above the
`luaL_Reg` table it describes, which is how one source declares several namespaces. This repository owns portable `lua/lib/` modules; shared UI owns theme application and persistence. Use `ble` for BLE/GATT `luaL_Reg` table it describes, which is how one source declares several namespaces. This repository owns portable `lua/lib/` modules; shared UI owns theme application and persistence. Use `ble` for BLE/GATT
and reserve `bt` for a future Classic Bluetooth contract. and reserve `bt` for a future Classic Bluetooth contract.
-174
View File
@@ -1,174 +0,0 @@
# Current API Landscape
Snapshot taken 2026-08-03 from the generated LuaLS stubs in Slate32 and CrossPoint Reader.
This inventory explains the gaps between the current firmwares and the contracts under
`lua/api/core/` and `lua/api/features/`.
## Summary
| | LCD / Slate32 | E-ink / CrossPoint |
|---|---:|---:|
| Declared functions | 77 | 67 |
| Names present in both | 27 | 27 |
| Same parameter list | 21 | 21 |
| Same name, different parameter list | 6 | 6 |
Matching declarations do not guarantee matching behavior. Important differences remain in
filesystem limits and ordering, TLS verification, network ownership, colors, and lifecycle.
## Original Common Ground
These names and parameter lists matched before the clean contract was defined. They are useful
implementation inventory, not constraints on `lua/api/core/`.
| Namespace | Functions | Remaining decisions |
|---|---|---|
| `http` | `get`, `head`, `delete`, `post`, `patch`, `download`, `urlencode` | The contract now returns `HttpResponse|nil, error`, requires a response `maxBytes`, and authenticates TLS. |
| `fs` | `exists`, `readFile`, `writeFile`, `listFiles`, `listDirs` | Standardize the read cap, sorted listings, path validation, and failure returns. |
| `log` | `debug`, `info`, `error` | Mostly ready. |
| `sys` | `delay`, `setTickInterval` | The clean contract drops both; callback timers replace periodic ticks and blocking delays. |
| `wifi` | `disconnect`, `isConnected` | Decide whether disconnect ownership is app-specific or device-wide. |
The basic `gui` drawing primitives are also intended for core. Colors will be opaque,
platform-native integers produced by `gui.color(r, g, b)`: RGB565 on LCD and quantized grayscale
on e-ink. Apps that need to be portable use `gui.color` rather than hard-coded values.
## Same Name, Different Signature
| Function | LCD | E-ink |
|---|---|---|
| `gui.clear` | `(color)` | `()` |
| `gui.drawLine` | `(x1, y1, x2, y2, color)` | `(x1, y1, x2, y2, width, color)` |
| `gui.drawText` | `(text, x, y, color, bg)` | `(font, x, y, text, color, style)` |
| `gui.fillCircle` | `(x, y, radius, color, bg)` | `(cx, cy, radius, color)` |
| `gui.getTextWidth` | `(text)` | `(font, text, style)` |
| `wifi.connect` | `(ssid, password)` | `()` using stored credentials |
These GUI differences are current implementation drift, not permanent display features. Wi-Fi
is also generic drift: both connection forms are useful, so the eventual shared signature can
make credentials optional after both firmwares implement both behaviors.
## Agreed GUI Direction
Basic geometry and text measurement belong in core with identical signatures:
```lua
gui.getWidth()
gui.getHeight()
gui.color(r, g, b)
gui.clear(color?)
gui.fillRect(x, y, w, h, color)
gui.drawRect(x, y, w, h, color)
gui.drawLine(x1, y1, x2, y2, color, width?)
gui.fillCircle(x, y, radius, color, background?)
gui.getTextWidth(font, text, style?)
gui.getFontHeight(font, style?)
gui.drawText(font, x, y, text, color?, style?, background?)
```
Fonts are opaque, zero-based integer IDs selected through shared semantic constants:
```lua
gui.FONT_SMALL
gui.FONT_UI
gui.FONT_BODY
gui.FONT_LARGE
```
Each implementation maps those roles to its available fonts or scales. Portable apps use the
named constants and metric functions rather than literal IDs or assumed pixel dimensions.
Physical names such as Bookerly and Noto Sans remain legacy implementation APIs, not part of
the core or e-ink feature contracts.
The optional background describes the surface behind anti-aliased output; it can have no visible
effect on a renderer without partial edge pixels. E-ink refresh policy and LCD live-frame
behavior remain feature-specific.
## Generic Features to Converge
These are not inherently LCD or e-ink concerns and should not be permanently assigned to a
display feature.
### Present only on E-ink
- Filesystem mutation and paging: `fileSize`, `mkdir`, `readLineAt`, `remove`, `removeTree`, `rename`.
These are intentionally under `lua/api/core/`; Slate32 must still implement them.
- Independent timers: `timer.after`, `timer.every`, `timer.cancel`
- `sys.uptime` / `sys.millis` naming
### Present only on LCD
- App routing: `sys.launch`, `sys.replace`, `sys.back`
- Runtime state: `sys.getAppName`, `setAppName`, `getMemory`, `isClockSynced`
- Wi-Fi management: `scan`, explicit credential connection, `forget`
- SD-backed `require` and app-local modules (runtime behavior, not a binding declaration)
- Native compact widget tree in `node.*`
The contract intentionally includes all of these generic capabilities. Both firmwares are under
our control, so they migrate to the clean API without compatibility aliases. The compact node
tree remains useful on e-ink for layout, painting, invalidation, and directional focus. Focus is
tree-level state with a deliberate appearance; moving it invalidates only the old and new nodes.
## Feature Contracts
- `buttons` guarantees physical-button polling/callbacks and button hints.
- `touch` guarantees calibrated/raw coordinates, touch callbacks, and calibration persistence.
A feature exists only where an app can call something a device may not have. Panel technology is
not such a case: firmware-owned publication and waveform policy already hold for every display, and
`gui.setFullscreen()` and `gui.roundRect()` are core, with a panel that has no gradient flattening
it exactly as `gui.color()` quantizes to grayscale. So the display is never a feature, and input is
never inferred from it. BLE/GATT remains
required core `ble`; a future `bt` namespace is reserved for Classic Bluetooth. Generic BMP,
polygon, shape, rotation, color, and text operations are core. Rotation and timezone settings are
also core, while theme persistence and application belong to shared `ui.lua`.
## Lifecycle Differences
| Concern | LCD | E-ink |
|---|---|---|
| Entry | required `init(arg)` | required `init()` |
| Navigation | Lua route stack | exit to C++ launcher |
| Input callbacks | touch down/move/up/tap | button callback and polling |
| Publication | drawing is immediately visible | apps currently call explicit refresh |
| Modules | app-local and shared `require` | effectively single-file apps |
The contract standardizes `init(arg?)`, navigation, and module loading. Optional `draw(deltaMs)`
runs once after initialization and then at most 30 FPS, best effort. The host passes monotonic
elapsed milliseconds, with zero on the first frame. Callback timers replace `on_tick`. Input
callbacks remain feature-specific. Firmware commits dirty content after callback batches and owns panel
refresh policy, including e-ink waveforms. `sys.getAPIVersion()` identifies the integer contract
version implemented by the firmware.
## Contract Rules
1. This is a clean API. Backward compatibility has no weight because all consumers are controlled
and migrate with the implementations.
2. A core function matches in name, parameters, returns, and observable behavior.
3. GUI coordinates use a top-left origin, including text; implementations translate driver
baselines and orientation details.
4. Invalid arguments raise Lua errors. Runtime failures return `nil, error`; filesystem reads
take an explicit `maxBytes` up to 64 KiB and fail rather than truncate. `fs.writeFile()` is
atomic: failure leaves the previous contents intact.
5. Apps live under `/.lua/apps`, shared modules under `/.lua/lib`, and persistent app data under
`/.lua/data/<AppId>`. Launch paths name directories relative to `apps`; firmware appends
`main.lua`. The first path component is the immutable app ID and determines `getAppDataPath()`.
6. `node` owns one focused ID outside its 16-byte nodes. Directional movement is geometric and
non-wrapping, with a distinct focused appearance.
7. `draw(deltaMs)` is the optional frame loop, capped at 30 FPS; the host supplies elapsed
monotonic milliseconds and callback-based `timer.after/every` handle periodic work.
8. Firmware commits dirty display content and owns panel refresh policy; no refresh API is
exposed to apps.
9. Apps are fully trusted; the contract adds no permissions or sandbox.
10. BLE/GATT is core `ble`; Classic Bluetooth remains undefined rather than sharing an inaccurate
namespace.
11. This repository owns portable modules under `lua/lib/`, including global theme behavior and the
declarative widget toolkit in `ui.lua`. Widgets expose only `on_enter`, `on_exit`, and
`on_click`: touch and directional focus map onto the same active-state lifecycle.
12. HTTPS authenticates certificates.
13. Generated firmware stubs are checked against this repository rather than copied here as
competing sources of truth.
14. Feature contracts contain only their declared hardware capability.
15. Runtime smoke tests cover semantics that LuaLS declarations cannot express.
+13 -4
View File
@@ -4,15 +4,19 @@ CC ?= cc
CXX ?= c++ CXX ?= c++
AR ?= ar AR ?= ar
CONTRACTS := $(sort $(wildcard lua/api/core/*.lua lua/api/features/*.lua)) CONTRACTS := $(sort $(wildcard lua/api/core/*.lua lua/api/features/*.lua lua/api/features/*/*.lua \
lua/api/lib/*.lua))
LUA_C := $(sort $(wildcard native/src/vendor/lua/*.c)) LUA_C := $(sort $(wildcard native/src/vendor/lua/*.c))
LUA_OBJECTS := $(patsubst native/src/vendor/lua/%.c,_build/lua/%.o,$(LUA_C)) LUA_OBJECTS := $(patsubst native/src/vendor/lua/%.c,_build/lua/%.o,$(LUA_C))
LUA_LIBRARY := _build/liblua.a LUA_LIBRARY := _build/liblua.a
CJSON_C := $(sort $(wildcard native/src/vendor/cjson/*.c))
CJSON_OBJECTS := $(patsubst native/src/vendor/cjson/%.c,_build/cjson/%.o,$(CJSON_C))
LUA_SMOKE := _build/lua-smoke LUA_SMOKE := _build/lua-smoke
RUNTIME_TEST := _build/runtime-test RUNTIME_TEST := _build/runtime-test
LUAC32 := _build/luac32 LUAC32 := _build/luac32
RUNTIME_CPP := $(sort $(wildcard native/src/runtime/*.cpp native/src/bindings/core/*.cpp \ RUNTIME_CPP := $(sort $(wildcard native/src/runtime/*.cpp native/src/bindings/core/*.cpp \
native/src/bindings/features/*.cpp) native/src/embedded_modules.cpp) native/src/bindings/lib/*.cpp \
native/src/bindings/features/*.cpp native/src/bindings/features/*/*.cpp) native/src/embedded_modules.cpp)
.PHONY: api test embed compiledb format .PHONY: api test embed compiledb format
@@ -46,9 +50,9 @@ test: $(LUA_SMOKE) $(RUNTIME_TEST) $(LUAC32)
$(LUA_SMOKE): native/test/lua_smoke.c $(LUA_LIBRARY) $(LUA_SMOKE): native/test/lua_smoke.c $(LUA_LIBRARY)
@$(CC) -std=c99 -DLUA_32BITS -I native/src/vendor/lua $< $(LUA_LIBRARY) -lm -ldl -o $@ @$(CC) -std=c99 -DLUA_32BITS -I native/src/vendor/lua $< $(LUA_LIBRARY) -lm -ldl -o $@
$(RUNTIME_TEST): $(RUNTIME_CPP) native/test/runtime_test.cpp $(LUA_LIBRARY) $(RUNTIME_TEST): $(RUNTIME_CPP) native/test/runtime_test.cpp $(LUA_LIBRARY) $(CJSON_OBJECTS)
@$(CXX) -std=c++11 -Wall -Wextra -DLUA_32BITS -I native/include -I native/src/vendor/lua \ @$(CXX) -std=c++11 -Wall -Wextra -DLUA_32BITS -I native/include -I native/src/vendor/lua \
$(RUNTIME_CPP) native/test/runtime_test.cpp $(LUA_LIBRARY) -lm -ldl -o $@ $(RUNTIME_CPP) native/test/runtime_test.cpp $(CJSON_OBJECTS) $(LUA_LIBRARY) -lm -ldl -o $@
$(LUA_LIBRARY): $(LUA_OBJECTS) $(LUA_LIBRARY): $(LUA_OBJECTS)
@$(AR) rcs $@ $^ @$(AR) rcs $@ $^
@@ -59,3 +63,8 @@ $(LUAC32): tools/dump.c $(LUA_LIBRARY)
_build/lua/%.o: native/src/vendor/lua/%.c _build/lua/%.o: native/src/vendor/lua/%.c
@mkdir -p $(@D) @mkdir -p $(@D)
@$(CC) -std=c99 -DLUA_32BITS -I native/src/vendor/lua -c $< -o $@ @$(CC) -std=c99 -DLUA_32BITS -I native/src/vendor/lua -c $< -o $@
# gnu99 rather than c99: lua-cjson calls strncasecmp, which is POSIX rather than ISO C.
_build/cjson/%.o: native/src/vendor/cjson/%.c
@mkdir -p $(@D)
@$(CC) -std=gnu99 -DLUA_32BITS -I native/src/vendor/lua -I native/src/vendor/cjson -c $< -o $@
+37 -24
View File
@@ -4,9 +4,8 @@ A future shared contract for Lua applications running on the ESP32 firmwares in
`../slate32` and `../crosspoint-reader`. `../slate32` and `../crosspoint-reader`.
Lua declarations and shared modules live under `lua/`; the vendored interpreter and shared C/C++ Lua declarations and shared modules live under `lua/`; the vendored interpreter and shared C/C++
runtime live under `native/`. [`LANDSCAPE.md`](LANDSCAPE.md) records how the contract differs from runtime live under `native/`. Runtime bindings remain authoritative until this repository is wired
the current firmwares. Runtime bindings remain authoritative until this repository is wired into into their tests.
their tests.
## Layout ## Layout
@@ -26,10 +25,14 @@ native/
``` ```
Every firmware implements all files under `lua/api/core/`. `sys.hasFeature(name)` declares Every firmware implements all files under `lua/api/core/`. `sys.hasFeature(name)` declares
optional features; claiming one guarantees every API and behavior in its matching file. Features optional features; claiming one guarantees every API and behavior in its matching file or
compose, so a device may expose both `touch` and `buttons`. Display technology is not a feature: directory. Features compose, so a device may expose both `touch` and `buttons`. A panel is the
an e-ink `GuiProvider` flattens a gradient the way `gui.color()` quantizes to grayscale, and the `screen` feature -- the `screen` and `tree` namespaces, including the saved rotation and theme --
firmware owns publication and waveform policy on every panel. because a headless firmware supplies no `GuiProvider`. Every namespace belongs to exactly one
feature or to core, which is why touch calibration is `touch.setCalibration()` rather than a
shared settings namespace three features write to. Display technology is still not a feature:
an e-ink `GuiProvider` flattens a gradient the way `screen.color()` quantizes to grayscale, and
the firmware owns publication and waveform policy on every panel.
The contract is the app-facing Lua API, not the provider C++ interface. Shared binding The contract is the app-facing Lua API, not the provider C++ interface. Shared binding
registrations carry LuaLS annotations; `tools/gen_api.py` mirrors them into `lua/api/`. Generated registrations carry LuaLS annotations; `tools/gen_api.py` mirrors them into `lua/api/`. Generated
@@ -37,6 +40,12 @@ files are committed for editors and checked for drift by `make test`, so a names
by the code that registers it, including the callbacks in `core/runtime.lua` and the feature files, by the code that registers it, including the callbacks in `core/runtime.lua` and the feature files,
which are generated from the `Runtime::call*` sites that fire them. which are generated from the `Runtime::call*` sites that fire them.
Callbacks are fields on the table an app returns, not globals, so each `@lua-app` block generates a
class rather than loose functions: `App` for the core contract, `TouchHandlers` and `ButtonHandlers`
alongside the namespaces they belong to. An app composes the ones it implements
(`---@class PaintApp : App, TouchHandlers`), which is as close to per-device stubs as static
declarations get -- what a firmware actually provides is still `sys.hasFeature()` at runtime.
Nothing under `lua/api/` ever runs: it is `---@meta` for editors and the drift check. `lua/lib/` Nothing under `lua/api/` ever runs: it is `---@meta` for editors and the drift check. `lua/lib/`
is the opposite -- real modules that ship to the SD card, so composition like `ui.lua` and is the opposite -- real modules that ship to the SD card, so composition like `ui.lua` and
`hints.lua` changes without a reflash. `hints.lua` changes without a reflash.
@@ -47,44 +56,48 @@ provider is how `sys.hasFeature()` answers false, and its namespace additions ar
registered. registered.
The declarations are a clean target, not the intersection of today's APIs. Existing apps and The declarations are a clean target, not the intersection of today's APIs. Existing apps and
firmwares migrate to it without compatibility aliases. Safe filesystem mutation, `node`, app firmwares migrate to it without compatibility aliases. Safe filesystem mutation, app
navigation, module loading, and `ble` are core even where a firmware does not implement them yet. navigation, module loading, and `ble` are core even where a firmware does not implement them yet.
Every app may use `require`; modules resolve from its app directory and `/.lua/lib`. This repository Every app may use `require`; the entry file points `package.path` wherever it keeps apps and
owns portable shared modules such as `ui.lua`; firmware-specific modules stay with their firmware. modules. This repository owns portable shared modules such as `ui.lua`; firmware-specific modules
stay with their firmware. The tree below is a convention of the Lua that boots, not something the
runtime knows -- it loads the one path it is given.
```text ```text
/.lua/ /.lua/
main.lua the entry file a firmware boots
apps/<AppId>/main.lua apps/<AppId>/main.lua
apps/<AppId>/<Subapp>/main.lua
data/<AppId>/ data/<AppId>/
lib/<module>.lua lib/<module>.lua
``` ```
The runtime owns app loading: `Runtime::startApp()` opens a fresh `lua_State`, points `require` at The runtime owns the teardown and nothing above it. `sys.startApp(path, args)` closes the
the app directory and `/.lua/lib`, runs the chunk, and calls `init(arg)`. `sys.launch`/`replace`/ `lua_State`, opens a fresh one, loads a path, and calls `start(args)` on the table it returns.
`back` only record intent, because swapping the state inside a callback would free the VM that is The arguments cross as JSON, because the table they came from dies with the state that built it;
still executing; the firmware calls `applyPendingNavigation()` between batches. History, the encoding happens while that state still lives, so an argument JSON cannot carry raises at the call
launcher fallback, app identity, and `sys.hasFeature()` all live there too, which is why rather than stranding a launch.
That is the whole of navigation. Routing, history, titles and data directories are decided by the
Lua file a firmware boots, since the only thing that structurally cannot live there is a value
that has to outlive the VM -- and the arguments are that value. `sys.startApp` records intent and
returns, because swapping the state inside a callback would free the VM still executing it; the
firmware calls `applyPendingNavigation()` between batches. With app identity gone from C++,
`SysProvider` is down to `millis`, `memory`, and `isClockSynced`. `SysProvider` is down to `millis`, `memory`, and `isClockSynced`.
`sys.launch("Settings/Calibration")` resolves the nested `main.lua`; only top-level apps appear in `draw(deltaMs)` is an optional frame loop called once after `start()` and then at most 30 FPS,
the launcher. The first path component is the immutable app ID, so every Settings route shares
`sys.getAppDataPath()` and `/.lua/data/Settings`. Package updates do not touch persistent data.
`draw(deltaMs)` is an optional frame loop called once after `init()` and then at most 30 FPS,
best effort. The host passes monotonic elapsed milliseconds (`0` on the first frame). Timers take best effort. The host passes monotonic elapsed milliseconds (`0` on the first frame). Timers take
Lua callbacks and return cancellation handles. Lua callbacks and return cancellation handles.
The firmware decides whether an event reaches an app at all -- jitter filtering, chrome, and The firmware decides whether an event reaches an app at all -- jitter filtering, chrome, and
debouncing are its business -- and `Runtime::call*` decides what the app sees. A release fires debouncing are its business -- and `Runtime::call*` decides what the app sees. A release fires
`on_touch_up` then the `on_touch` alias, and a button release fires `on_button_up` then `on_touch_up` then the `on_touch` alias, and a button release fires `on_button_up` then
`on_button`, so the ordering is identical on every device. Only a failed `init()` stops an app; `on_button`, so the ordering is identical on every device. Only a failed `start()` stops an app;
every other callback logs through `LogProvider` and carries on. Calling a feature callback without every other callback logs through `LogProvider` and carries on. Calling a feature callback without
its provider is a wiring bug and says so. its provider is a wiring bug and says so.
The firmware commits dirty display content after callback batches and owns e-ink waveform The firmware commits dirty display content after callback batches and owns e-ink waveform
policy; apps do not refresh the panel manually. Apps are fully trusted with the complete core API. policy; apps do not refresh the panel manually. Apps are fully trusted with the complete core API.
Theme persistence and application belong to shared `ui.lua`, not the firmware `settings` binding. Theme application belongs to shared `ui.lua`; `screen.setTheme()` only stores the name.
The native library vendors Lua 5.4.8 from GitHub tag `v5.4.8` and compiles it with `LUA_32BITS`. The native library vendors Lua 5.4.8 from GitHub tag `v5.4.8` and compiles it with `LUA_32BITS`.
Command-line and upstream test entry points are excluded; `luaconf.h` carries one documented guard Command-line and upstream test entry points are excluded; `luaconf.h` carries one documented guard
@@ -92,7 +105,7 @@ that lets the build flag select 32-bit number mode.
## Shared UI ## Shared UI
Portable apps normally use the declarative `ui.lua` toolkit; `node` remains the low-level escape Portable apps normally use the declarative `ui.lua` toolkit; `tree` remains the low-level escape
hatch. The baseline constructors are `screen`, `box`, `spacer`, `text`, `label`, `button`, hatch. The baseline constructors are `screen`, `box`, `spacer`, `text`, `label`, `button`,
`custom`, and `confirm`. A screen accepts both touch and physical-button input. `custom`, and `confirm`. A screen accepts both touch and physical-button input.
+2 -1
View File
@@ -7,7 +7,8 @@
"includeDir": "native/include", "includeDir": "native/include",
"flags": [ "flags": [
"-DLUA_32BITS", "-DLUA_32BITS",
"-I native/src/vendor/lua" "-I native/src/vendor/lua",
"-I native/src/vendor/cjson"
] ]
} }
} }
+23 -6
View File
@@ -2,10 +2,16 @@
-- Generated from native/src/bindings/core/ble.cpp. Do not edit. -- Generated from native/src/bindings/core/ble.cpp. Do not edit.
---@class BleDevice ---@class BleObservation
---@field name string
---@field address string ---@field address string
---@field name string
---@field rssi integer ---@field rssi integer
---@field payload string Raw advertisement bytes.
---@field lastSeenMs integer sys.getMillis() at last sighting.
---@class BleFilter
---@field services? string[] Service-data UUIDs to keep.
---@field manufacturers? integer[] Manufacturer ids to keep.
---@class BleLib ---@class BleLib
ble = {} ble = {}
@@ -23,11 +29,22 @@ function ble.deinit() end
---@return boolean ---@return boolean
function ble.isInitialized() end function ble.isInitialized() end
---Scans for advertising devices. ---Starts passively observing advertisements, coalesced per device.
---@param durationMs? integer Defaults to 3000. ---@param filter? BleFilter Keep only matching adverts; nil keeps all.
---@return BleDevice[]? devices ---@return true? ok
---@return string? error ---@return string? error
function ble.scan(durationMs) end function ble.observe(filter) end
---Stops observing and clears the snapshot.
function ble.unobserve() end
---Whether advertisement observation is running.
---@return boolean
function ble.isObserving() end
---The current snapshot of observed devices.
---@return BleObservation[] devices
function ble.observed() end
---Connects to a peripheral. ---Connects to a peripheral.
---@param address string ---@param address string
-151
View File
@@ -1,151 +0,0 @@
---@meta
-- Generated from native/src/bindings/core/gui.cpp. Do not edit.
---@alias GuiColor integer
---@alias GuiFont integer
---@alias GuiTextStyle integer
---@class GuiLib
---@field FONT_SMALL GuiFont Small auxiliary text.
---@field FONT_UI GuiFont Normal controls and labels.
---@field FONT_BODY GuiFont Normal reading text.
---@field FONT_LARGE GuiFont Headings and prominent values.
---@field STYLE_NORMAL GuiTextStyle
---@field STYLE_BOLD GuiTextStyle
gui = {}
gui.FONT_SMALL = 0
gui.FONT_UI = 0
gui.FONT_BODY = 0
gui.FONT_LARGE = 0
gui.STYLE_NORMAL = 0
gui.STYLE_BOLD = 0
---Returns the live frame width.
---@return integer
function gui.getWidth() end
---Returns the live frame height.
---@return integer
function gui.getHeight() end
---Rotates the live frame without changing the saved preference.
---@param degrees integer 0, 90, 180, or 270 clockwise.
function gui.setRotation(degrees) end
---Returns the rotation of the live frame.
---@return integer Degrees clockwise for the live frame.
function gui.getRotation() end
---Returns an opaque native color. E-ink implementations quantize RGB to available grayscale.
---@param r integer 0 through 255.
---@param g integer 0 through 255.
---@param b integer 0 through 255.
---@return GuiColor
function gui.color(r, g, b) end
---Clears the frame.
---@param color? GuiColor Defaults to white.
function gui.clear(color) end
---Fills a rectangle.
---@param x integer
---@param y integer
---@param w integer
---@param h integer
---@param color GuiColor
function gui.fillRect(x, y, w, h, color) end
---Outlines a rectangle.
---@param x integer
---@param y integer
---@param w integer
---@param h integer
---@param color GuiColor
function gui.drawRect(x, y, w, h, color) end
---Draws a line.
---@param x1 integer
---@param y1 integer
---@param x2 integer
---@param y2 integer
---@param color GuiColor
---@param width? integer Defaults to one pixel.
function gui.drawLine(x1, y1, x2, y2, color, width) end
---Draws a single pixel.
---@param x integer
---@param y integer
---@param color GuiColor
function gui.drawPixel(x, y, color) end
---Outlines a circle.
---@param x integer Center.
---@param y integer Center.
---@param radius integer
---@param color GuiColor
---@param width? integer Defaults to one pixel.
function gui.drawCircle(x, y, radius, color, width) end
---Fills a circle.
---@param x integer Center.
---@param y integer Center.
---@param radius integer
---@param color GuiColor
---@param background? GuiColor Surface behind an anti-aliased edge.
function gui.fillCircle(x, y, radius, color, background) end
---Draws an anti-aliased rounded fill, optional gradient, and optional border in one pass.
---@param x integer
---@param y integer
---@param w integer
---@param h integer
---@param radius integer
---@param background GuiColor Surface behind the anti-aliased edge.
---@param top? GuiColor Fill, or gradient top; omitted for no fill.
---@param bottom? GuiColor Gradient bottom; defaults to top. Panels without a gradient use top.
---@param border? GuiColor Omitted for no border.
function gui.roundRect(x, y, w, h, radius, background, top, bottom, border) end
---Temporarily gives the app the full panel, including firmware chrome.
---@param on boolean
function gui.setFullscreen(on) end
---Fills a polygon.
---@param xs integer[]
---@param ys integer[]
---@param color GuiColor
function gui.fillPolygon(xs, ys, color) end
---Draws a bitmap.
---@param path string Absolute BMP path.
---@param x? integer Left edge; defaults to centered.
---@param y? integer Top edge; defaults to centered.
---@param maxWidth? integer Defaults to panel width.
---@param maxHeight? integer Defaults to panel height.
---@return true? ok
---@return string? error
function gui.drawBmp(path, x, y, maxWidth, maxHeight) end
---Measures a text run.
---@param font GuiFont Use a named gui.FONT_* role.
---@param text string
---@param style? GuiTextStyle Defaults to gui.STYLE_NORMAL.
---@return integer
function gui.getTextWidth(font, text, style) end
---Returns the line height of a font role.
---@param font GuiFont Use a named gui.FONT_* role.
---@param style? GuiTextStyle Defaults to gui.STYLE_NORMAL.
---@return integer
function gui.getFontHeight(font, style) end
---Draws a text run with its top-left corner at x, y.
---@param font GuiFont Use a named gui.FONT_* role.
---@param x integer Left edge.
---@param y integer Top edge.
---@param text string
---@param color? GuiColor Defaults to black.
---@param style? GuiTextStyle Defaults to gui.STYLE_NORMAL.
---@param background? GuiColor Omitted for transparent text.
function gui.drawText(font, x, y, text, color, style, background) end
+9 -13
View File
@@ -2,21 +2,17 @@
-- Generated from native/src/runtime/runtime.cpp. Do not edit. -- Generated from native/src/runtime/runtime.cpp. Do not edit.
-- Runtime layout: -- The firmware loads the path it was booted with into every fresh state and calls
-- /.lua/apps/<AppId>/main.lua application entry point -- these on the table it returns. Where apps live, what surrounds them and which of
-- /.lua/apps/<AppId>/<Subapp>/main.lua nested route, omitted from the launcher -- these an app itself sees are all that file's to decide, which is why an app
-- /.lua/data/<AppId>/ persistent app data, preserved across updates -- composes the classes for the features it handles:
-- /.lua/lib/<module>.lua shared require() modules --
-- require() also searches the running application's directory -- ---@class PaintApp : App, TouchHandlers
-- --
-- The firmware does not clear the frame before calling draw(), and commits changed -- The firmware does not clear the frame before calling draw(), and commits changed
-- display content after each callback batch using the panel's own refresh policy. -- display content after each callback batch using the panel's own refresh policy.
-- Timer callbacks are registered directly with timer.after/every. -- Timer callbacks are registered directly with timer.after/every.
---Required. Runs once before the first draw; failing here stops the app. ---@class App
---@param arg? string The string passed to sys.launch or sys.replace. ---@field start fun(args?: table) Required. Mounts whatever the arguments describe; failing here leaves no app running.
function init(arg) end ---@field draw? fun(deltaMs: integer) Optional frame loop, called once after start and then at most 30 FPS, best effort.
---Optional frame loop, called once after init and then at most 30 FPS, best effort.
---@param deltaMs integer Monotonic milliseconds since the previous draw; zero on the first.
function draw(deltaMs) end
-26
View File
@@ -1,26 +0,0 @@
---@meta
-- Generated from native/src/bindings/core/settings.cpp. Do not edit.
---@class SettingsLib
settings = {}
---Returns the saved rotation in degrees clockwise.
---@return integer
function settings.getRotation() end
---Applies and persists the screen rotation.
---@param degrees integer 0, 90, 180, or 270 clockwise.
---@return true? ok
---@return string? error
function settings.setRotation(degrees) end
---Returns the active POSIX timezone rule.
---@return string
function settings.getTimezone() end
---Applies and persists a POSIX timezone rule.
---@param timezone string
---@return true? ok
---@return string? error
function settings.setTimezone(timezone) end
+15 -29
View File
@@ -2,7 +2,7 @@
-- Generated from native/src/bindings/core/sys.cpp. Do not edit. -- Generated from native/src/bindings/core/sys.cpp. Do not edit.
---@alias Feature "touch"|"buttons" ---@alias Feature "screen"|"touch"|"buttons"
---@class SysLib ---@class SysLib
sys = {} sys = {}
@@ -20,34 +20,10 @@ function sys.hasFeature(feature) end
---@return integer ---@return integer
function sys.getMillis() end function sys.getMillis() end
---Returns the immutable first path component of the running app. ---Tears the runtime down and starts over from a Lua file, which is the only navigation there is: history, titles and where apps live are whatever that file makes of the arguments.
---@return string ---@param path string Absolute path to the Lua file to load; traversal is rejected.
function sys.getAppID() end ---@param args? table Plain data, carried across the teardown as JSON and handed to start(args). Raises on anything JSON cannot represent.
function sys.startApp(path, args) end
---Returns the running app title, initially the app ID.
---@return string
function sys.getAppTitle() end
---Returns the current app's guaranteed-existing persistent data directory.
---@return string Absolute path under /.lua/data, preserved across app updates.
function sys.getAppDataPath() end
---Changes the running app's display title.
---@param title string
function sys.setAppTitle(title) end
---Launches /.lua/apps/<path>/main.lua and pushes the current route.
---@param path string App-relative directory path; traversal is rejected.
---@param arg? string Passed to init(arg).
function sys.launch(path, arg) end
---Launches an app path without retaining the current route.
---@param path string App-relative directory path; traversal is rejected.
---@param arg? string Passed to init(arg).
function sys.replace(path, arg) end
---Returns to the previous app, or the launcher when history is empty.
function sys.back() end
---Returns heap statistics. ---Returns heap statistics.
---@return integer freeBytes ---@return integer freeBytes
@@ -58,3 +34,13 @@ function sys.getMemory() end
---Whether network time synchronization has completed. ---Whether network time synchronization has completed.
---@return boolean ---@return boolean
function sys.isClockSynced() end function sys.isClockSynced() end
---Returns the active POSIX timezone rule.
---@return string
function sys.getTimezone() end
---Applies and persists a POSIX timezone rule.
---@param timezone string
---@return true? ok
---@return string? error
function sys.setTimezone(timezone) end
+15 -18
View File
@@ -5,42 +5,39 @@
---@alias Button "up"|"down"|"left"|"right"|"confirm"|"back" ---@alias Button "up"|"down"|"left"|"right"|"confirm"|"back"
-- Roles, not physical buttons: a device maps whatever hardware it has onto them, and -- Roles, not physical buttons: a device maps whatever hardware it has onto them, and
-- up/down/left/right are the directions node.moveFocus already takes. -- up/down/left/right are the directions tree.moveFocus already takes.
---@class InputLib ---@class ButtonsLib
input = input or {} buttons = {}
---Returns the roles this device reports, so an app can label only the actions it has. ---Returns the roles this device reports, so an app can label only the actions it has.
---@return Button[] ---@return Button[]
function input.getButtons() end function buttons.getAll() end
---Whether any button is held. ---Whether any button is held.
---@return boolean ---@return boolean
function input.isAnyPressed() end function buttons.isAnyPressed() end
---Whether a button is held. ---Whether a button is held.
---@param button Button ---@param button Button
---@return boolean ---@return boolean
function input.isPressed(button) end function buttons.isPressed(button) end
---Whether a button went down since the last poll. ---Whether a button went down since the last poll.
---@param button Button ---@param button Button
---@return boolean ---@return boolean
function input.wasPressed(button) end function buttons.wasPressed(button) end
---Whether a button came up since the last poll. ---Whether a button came up since the last poll.
---@param button Button ---@param button Button
---@return boolean ---@return boolean
function input.wasReleased(button) end function buttons.wasReleased(button) end
---Fired when a button goes down. -- What an app implements to see buttons, composed into its own class:
---@param button Button --
function on_button_down(button) end -- ---@class MenuApp : App, ButtonHandlers
---Fired when a button comes up. ---@class ButtonHandlers
---@param button Button ---@field onButtonDown? fun(button: Button) Fired when a button goes down.
function on_button_up(button) end ---@field onButtonUp? fun(button: Button) Fired when a button comes up.
---@field onButton? fun(button: Button) Tap alias, fired on release like a click, after onButtonUp.
---Tap alias, fired on release like a click, after on_button_up.
---@param button Button
function on_button(button) end
+164
View File
@@ -0,0 +1,164 @@
---@meta
-- Generated from native/src/bindings/features/screen/screen.cpp. Do not edit.
-- The panel itself; the widget tree it paints is `tree`, and
-- sys.hasFeature("screen") covers both.
---@alias ScreenColor integer
---@alias ScreenFont integer
---@alias ScreenTextStyle integer
---@class ScreenLib
---@field FONT_SMALL ScreenFont Small auxiliary text.
---@field FONT_UI ScreenFont Normal controls and labels.
---@field FONT_BODY ScreenFont Normal reading text.
---@field FONT_LARGE ScreenFont Headings and prominent values.
---@field STYLE_NORMAL ScreenTextStyle
---@field STYLE_BOLD ScreenTextStyle
screen = {}
screen.FONT_SMALL = 0
screen.FONT_UI = 0
screen.FONT_BODY = 0
screen.FONT_LARGE = 0
screen.STYLE_NORMAL = 0
screen.STYLE_BOLD = 0
---Returns the live frame width.
---@return integer
function screen.getWidth() end
---Returns the live frame height.
---@return integer
function screen.getHeight() end
---Rotates the panel and persists the choice, so there is one rotation
---rather than a live one and a saved one to reconcile.
---@param degrees integer 0, 90, 180, or 270 clockwise.
---@return true? ok
---@return string? error
function screen.setRotation(degrees) end
---Returns the rotation in degrees clockwise.
---@return integer
function screen.getRotation() end
---Returns the saved palette name. Apps read ui.getTheme() instead; this
---is the stored value, which only ui.setTheme() knows how to apply.
---@return string
function screen.getTheme() end
---Persists a palette name without applying it. Call ui.setTheme(), which
---writes through here and then rebuilds the palette and repaints.
---@param theme string
---@return true? ok
---@return string? error
function screen.setTheme(theme) end
---Returns an opaque native color. E-ink implementations quantize RGB to available grayscale.
---@param r integer 0 through 255.
---@param g integer 0 through 255.
---@param b integer 0 through 255.
---@return ScreenColor
function screen.color(r, g, b) end
---Clears the frame.
---@param color? ScreenColor Defaults to white.
function screen.clear(color) end
---Fills a rectangle.
---@param x integer
---@param y integer
---@param w integer
---@param h integer
---@param color ScreenColor
function screen.fillRect(x, y, w, h, color) end
---Outlines a rectangle.
---@param x integer
---@param y integer
---@param w integer
---@param h integer
---@param color ScreenColor
function screen.drawRect(x, y, w, h, color) end
---Draws a line.
---@param x1 integer
---@param y1 integer
---@param x2 integer
---@param y2 integer
---@param color ScreenColor
---@param width? integer Defaults to one pixel.
function screen.drawLine(x1, y1, x2, y2, color, width) end
---Draws a single pixel.
---@param x integer
---@param y integer
---@param color ScreenColor
function screen.drawPixel(x, y, color) end
---Outlines a circle.
---@param x integer Center.
---@param y integer Center.
---@param radius integer
---@param color ScreenColor
---@param width? integer Defaults to one pixel.
function screen.drawCircle(x, y, radius, color, width) end
---Fills a circle.
---@param x integer Center.
---@param y integer Center.
---@param radius integer
---@param color ScreenColor
---@param background? ScreenColor Surface behind an anti-aliased edge.
function screen.fillCircle(x, y, radius, color, background) end
---Draws an anti-aliased rounded fill, optional gradient, and optional border in one pass.
---@param x integer
---@param y integer
---@param w integer
---@param h integer
---@param radius integer
---@param background ScreenColor Surface behind the anti-aliased edge.
---@param top? ScreenColor Fill, or gradient top; omitted for no fill.
---@param bottom? ScreenColor Gradient bottom; defaults to top. Panels without a gradient use top.
---@param border? ScreenColor Omitted for no border.
function screen.roundRect(x, y, w, h, radius, background, top, bottom, border) end
---Fills a polygon.
---@param xs integer[]
---@param ys integer[]
---@param color ScreenColor
function screen.fillPolygon(xs, ys, color) end
---Draws a bitmap.
---@param path string Absolute BMP path.
---@param x? integer Left edge; defaults to centered.
---@param y? integer Top edge; defaults to centered.
---@param maxWidth? integer Defaults to panel width.
---@param maxHeight? integer Defaults to panel height.
---@return true? ok
---@return string? error
function screen.drawBmp(path, x, y, maxWidth, maxHeight) end
---Measures a text run.
---@param font ScreenFont Use a named screen.FONT_* role.
---@param text string
---@param style? ScreenTextStyle Defaults to screen.STYLE_NORMAL.
---@return integer
function screen.getTextWidth(font, text, style) end
---Returns the line height of a font role.
---@param font ScreenFont Use a named screen.FONT_* role.
---@param style? ScreenTextStyle Defaults to screen.STYLE_NORMAL.
---@return integer
function screen.getFontHeight(font, style) end
---Draws a text run with its top-left corner at x, y.
---@param font ScreenFont Use a named screen.FONT_* role.
---@param x integer Left edge.
---@param y integer Top edge.
---@param text string
---@param color? ScreenColor Defaults to black.
---@param style? ScreenTextStyle Defaults to screen.STYLE_NORMAL.
---@param background? ScreenColor Omitted for transparent text.
function screen.drawText(font, x, y, text, color, style, background) end
@@ -1,6 +1,6 @@
---@meta ---@meta
-- Generated from native/src/bindings/core/node.cpp. Do not edit. -- Generated from native/src/bindings/features/screen/tree.cpp. Do not edit.
---@alias NodeId integer ---@alias NodeId integer
---@alias NodeType "box"|"text"|"button"|"custom" ---@alias NodeType "box"|"text"|"button"|"custom"
@@ -16,46 +16,45 @@
---@field justify? "start"|"center"|"end"|"between" ---@field justify? "start"|"center"|"end"|"between"
---@field row? boolean ---@field row? boolean
---@field at? table Absolute-position fields. ---@field at? table Absolute-position fields.
---@field capture? boolean
---@field interactive? boolean ---@field interactive? boolean
---@field label? string ---@field label? string
---@field font? GuiFont ---@field font? ScreenFont
---@class NodeStyle ---@class NodeStyle
---@field color? GuiColor ---@field color? ScreenColor
---@field background? GuiColor Background offered to descendants. ---@field background? ScreenColor Background offered to descendants.
---@field fill? GuiColor Surface painted by a box. ---@field fill? ScreenColor Surface painted by a box.
---@field border? GuiColor ---@field border? ScreenColor
---@field face? GuiColor Default button surface. ---@field face? ScreenColor Default button surface.
---@field pressedFace? GuiColor Pressed button surface. ---@field pressedFace? ScreenColor Pressed button surface.
---@field pressedColor? GuiColor Pressed button text. ---@field pressedColor? ScreenColor Pressed button text.
---@field focusColor? GuiColor Distinct outline for directional focus. ---@field focusColor? ScreenColor Distinct outline for directional focus.
---@field radius? integer ---@field radius? integer
---@field font? GuiFont ---@field font? ScreenFont
---@field textStyle? GuiTextStyle ---@field textStyle? ScreenTextStyle
---@class NodeLib ---@class TreeLib
node = {} tree = {}
---Drops the current tree; all existing IDs become invalid. ---Drops the current tree; all existing IDs become invalid.
function node.reset() end function tree.reset() end
---Creates a node, optionally as a child of an existing one. ---Creates a node, optionally as a child of an existing one.
---@param parent? NodeId Nil creates a root. ---@param parent? NodeId Nil creates a root.
---@param spec NodeSpec ---@param spec NodeSpec
---@return NodeId ---@return NodeId
function node.create(parent, spec) end function tree.create(parent, spec) end
---Adopts an existing root as a child. ---Adopts an existing root as a child.
---@param parent NodeId ---@param parent NodeId
---@param child NodeId Existing root without a parent. ---@param child NodeId Existing root without a parent.
function node.attach(parent, child) end function tree.attach(parent, child) end
---Changes a node's requested size before layout. ---Changes a node's requested size before layout.
---@param id NodeId ---@param id NodeId
---@param w? number|"fill"|"auto" ---@param w? number|"fill"|"auto"
---@param h? number|"fill"|"auto" ---@param h? number|"fill"|"auto"
function node.setSize(id, w, h) end function tree.setSize(id, w, h) end
---Measures and places a subtree. ---Measures and places a subtree.
---@param root NodeId ---@param root NodeId
@@ -65,17 +64,17 @@ function node.setSize(id, w, h) end
---@param h integer ---@param h integer
---@return true? ok ---@return true? ok
---@return string? error ---@return string? error
function node.layout(root, x, y, w, h) end function tree.layout(root, x, y, w, h) end
---Releases temporary measurement and placement inputs after layout. ---Releases temporary measurement and placement inputs after layout.
function node.dropScratch() end function tree.dropScratch() end
---Returns the deepest interactive node under a point. ---Returns the deepest interactive node under a point.
---@param root NodeId ---@param root NodeId
---@param x integer ---@param x integer
---@param y integer ---@param y integer
---@return NodeId? ---@return NodeId?
function node.hit(root, x, y) end function tree.hit(root, x, y) end
---Returns a node's placed rectangle. ---Returns a node's placed rectangle.
---@param id NodeId ---@param id NodeId
@@ -83,73 +82,89 @@ function node.hit(root, x, y) end
---@return integer y ---@return integer y
---@return integer w ---@return integer w
---@return integer h ---@return integer h
function node.getRect(id) end function tree.getRect(id) end
---Pans a scrollable node's children, clamped to the content. The node is marked dirty, so the next draw repaints it.
---@param id NodeId
---@param x integer
---@param y integer
function tree.setScroll(id, x, y) end
---Returns a scrollable node's current offset, or zeroes.
---@param id NodeId
---@return integer x, integer y
function tree.getScroll(id) end
---Returns how far each axis can pan before the content's far edge reaches the box. Zero on an axis whose content fits.
---@param id NodeId
---@return integer maxX, integer maxY
function tree.getScrollRange(id) end
---Replaces a node's text and marks it for repaint. ---Replaces a node's text and marks it for repaint.
---@param id NodeId ---@param id NodeId
---@param text string ---@param text string
function node.setLabel(id, text) end function tree.setLabel(id, text) end
---Returns a node's text. ---Returns a node's text.
---@param id NodeId ---@param id NodeId
---@return string? ---@return string?
function node.getLabel(id) end function tree.getLabel(id) end
---Returns a node's parent. ---Returns a node's parent.
---@param id NodeId ---@param id NodeId
---@return NodeId? ---@return NodeId?
function node.getParent(id) end function tree.getParent(id) end
---Sets the style roles a subtree inherits. ---Sets the style roles a subtree inherits.
---@param id NodeId ---@param id NodeId
---@param style NodeStyle ---@param style NodeStyle
function node.setStyle(id, style) end function tree.setStyle(id, style) end
---Marks a node for repaint. ---Marks a node for repaint.
---@param id NodeId ---@param id NodeId
function node.invalidate(id) end function tree.invalidate(id) end
---Sets a node's pressed state. ---Sets a node's pressed state.
---@param id NodeId ---@param id NodeId
---@param pressed boolean ---@param pressed boolean
function node.setPressed(id, pressed) end function tree.setPressed(id, pressed) end
---Whether a node is pressed. ---Whether a node is pressed.
---@param id NodeId ---@param id NodeId
---@return boolean ---@return boolean
function node.isPressed(id) end function tree.isPressed(id) end
---Focuses the first interactive node in layout order. ---Focuses the first interactive node in layout order.
---@param root NodeId ---@param root NodeId
---@return NodeId? focused ---@return NodeId? focused
function node.focusFirst(root) end function tree.focusFirst(root) end
---Changes focus and invalidates the previously and newly focused nodes. ---Changes focus and invalidates the previously and newly focused nodes.
---@param id? NodeId Nil clears focus. ---@param id? NodeId Nil clears focus.
function node.setFocus(id) end function tree.setFocus(id) end
---Returns the focused node. ---Returns the focused node.
---@return NodeId? ---@return NodeId?
function node.getFocus() end function tree.getFocus() end
---Moves to the nearest interactive node in the requested direction without wrapping. ---Moves to the nearest interactive node in the requested direction without wrapping.
---@param root NodeId ---@param root NodeId
---@param direction NodeDirection ---@param direction NodeDirection
---@return NodeId? focused Current focus when no candidate exists. ---@return NodeId? focused Current focus when no candidate exists.
function node.moveFocus(root, direction) end function tree.moveFocus(root, direction) end
---Registers the painter every custom node calls. ---Registers the painter every custom node calls. The clip arguments are the region of the node being painted now -- one band of a composited repaint -- so a painter can cull to it instead of redrawing itself once per band.
---@param painter fun(id: NodeId, x: integer, y: integer, w: integer, h: integer) ---@param painter fun(id: NodeId, x: integer, y: integer, w: integer, h: integer, clipX: integer, clipY: integer, clipW: integer, clipH: integer)
function node.setPainter(painter) end function tree.setPainter(painter) end
---Paints dirty nodes; the firmware owns publication to the physical display. ---Paints dirty nodes; the firmware owns publication to the physical display.
---@param root NodeId ---@param root NodeId
function node.draw(root) end function tree.draw(root) end
---Returns the number of nodes in the tree. ---Returns the number of nodes in the tree.
---@return integer ---@return integer
function node.getCount() end function tree.getCount() end
---Returns the tree's memory use. ---Returns the tree's memory use.
---@return integer bytes ---@return integer bytes
function node.getFootprint() end function tree.getFootprint() end
+25 -38
View File
@@ -2,8 +2,22 @@
-- Generated from native/src/bindings/features/touch.cpp. Do not edit. -- Generated from native/src/bindings/features/touch.cpp. Do not edit.
---@class SettingsLib ---@class TouchLib
settings = settings or {} touch = {}
---Returns the calibrated touch point, or nothing when the panel is not touched.
---@return integer? x
---@return integer? y
function touch.getPoint() end
---Returns the uncalibrated touch reading, or nothing when the panel is not touched.
---@return integer? x
---@return integer? y
function touch.getRawPoint() end
---Whether the panel is currently touched.
---@return boolean
function touch.isTouched() end
---Persists the panel's touch calibration. ---Persists the panel's touch calibration.
---@param x0 integer Raw reading at the left edge. ---@param x0 integer Raw reading at the left edge.
@@ -12,41 +26,14 @@ settings = settings or {}
---@param y1 integer Raw reading at the bottom edge. ---@param y1 integer Raw reading at the bottom edge.
---@return true? ok ---@return true? ok
---@return string? error ---@return string? error
function settings.setCalibration(x0, y0, x1, y1) end function touch.setCalibration(x0, y0, x1, y1) end
---@class InputLib -- What an app implements to see raw touch, composed into its own class:
input = input or {} --
-- ---@class PaintApp : App, TouchHandlers
---Returns the calibrated touch point, or nothing when the panel is not touched. ---@class TouchHandlers
---@return integer? x ---@field onTouchDown? fun(x: integer, y: integer) Fired when the finger lands.
---@return integer? y ---@field onTouchMove? fun(x: integer, y: integer) Fired when the finger moves while down, after the firmware's jitter filter.
function input.getTouch() end ---@field onTouchUp? fun(x: integer, y: integer) Fired when the finger lifts.
---@field onTouch? fun(x: integer, y: integer) Tap alias, fired on release like a click, after onTouchUp.
---Returns the uncalibrated touch reading, or nothing when the panel is not touched.
---@return integer? x
---@return integer? y
function input.getRawTouch() end
---Whether the panel is currently touched.
---@return boolean
function input.isTouched() end
---Fired when the finger lands.
---@param x integer
---@param y integer
function on_touch_down(x, y) end
---Fired when the finger moves while down, after the firmware's jitter filter.
---@param x integer
---@param y integer
function on_touch_move(x, y) end
---Fired when the finger lifts.
---@param x integer
---@param y integer
function on_touch_up(x, y) end
---Tap alias, fired on release like a click, after on_touch_up.
---@param x integer
---@param y integer
function on_touch(x, y) end
+27
View File
@@ -0,0 +1,27 @@
---@meta cjson
-- Hand-written, unlike the rest of lua/api: the implementation is lua-cjson under
-- native/src/vendor/cjson, so there are no binding annotations to generate from.
-- Depth is capped at 32 in native/src/bindings/lib/cjson.cpp, because decoding
-- recurses on the C stack and the upstream default of 1000 assumes a server.
--
-- Both functions raise on bad input rather than returning nil plus a message, so
-- a response from the network is worth a pcall.
---@class CJsonLib
local cjson = {}
---The value a JSON null decodes to, distinct from nil so a key survives it.
cjson.null = nil
---Parses JSON text.
---@param text string
---@return any value Tables, strings, numbers, booleans, or cjson.null.
function cjson.decode(text) end
---Serializes plain Lua data as JSON.
---@param value any Sequences become arrays; every other table becomes an object.
---@return string text
function cjson.encode(value) end
return cjson
+11 -11
View File
@@ -8,8 +8,8 @@ local ORDER = { "back", "left", "up", "down", "right", "confirm" }
local function available() local function available()
local roles = {} local roles = {}
if input and input.getButtons then if buttons and buttons.getAll then
for _, role in ipairs(input.getButtons()) do for _, role in ipairs(buttons.getAll()) do
roles[role] = true roles[role] = true
end end
end end
@@ -21,11 +21,11 @@ end
---@param options table|nil `y`, `font`, `color`, and `background` overrides. ---@param options table|nil `y`, `font`, `color`, and `background` overrides.
function hints.draw(actions, options) function hints.draw(actions, options)
options = options or {} options = options or {}
local font = options.font or gui.FONT_SMALL local font = options.font or screen.FONT_SMALL
local color = options.color or gui.color(0, 0, 0) local color = options.color or screen.color(0, 0, 0)
local background = options.background or gui.color(255, 255, 255) local background = options.background or screen.color(255, 255, 255)
local height = gui.getFontHeight(font) + 6 local height = screen.getFontHeight(font) + 6
local y = options.y or (gui.getHeight() - height) local y = options.y or (screen.getHeight() - height)
local roles = available() local roles = available()
local labels = {} local labels = {}
@@ -35,15 +35,15 @@ function hints.draw(actions, options)
end end
end end
gui.fillRect(0, y, gui.getWidth(), height, background) screen.fillRect(0, y, screen.getWidth(), height, background)
if #labels == 0 then if #labels == 0 then
return height return height
end end
local slot = gui.getWidth() // #labels local slot = screen.getWidth() // #labels
for index, label in ipairs(labels) do for index, label in ipairs(labels) do
local left = slot * (index - 1) + (slot - gui.getTextWidth(font, label)) // 2 local left = slot * (index - 1) + (slot - screen.getTextWidth(font, label)) // 2
gui.drawText(font, left, y + 3, label, color, gui.STYLE_NORMAL, background) screen.drawText(font, left, y + 3, label, color, screen.STYLE_NORMAL, background)
end end
return height return height
end end
+301 -103
View File
@@ -12,7 +12,6 @@ local ui = {}
---@field justify? "start"|"center"|"end"|"between" ---@field justify? "start"|"center"|"end"|"between"
---@field row? boolean ---@field row? boolean
---@field at? table ---@field at? table
---@field capture? boolean
---@field label? string ---@field label? string
---@field font? GuiFont ---@field font? GuiFont
---@field style? GuiTextStyle ---@field style? GuiTextStyle
@@ -27,8 +26,10 @@ local ui = {}
---@field on_enter? UiHandler ---@field on_enter? UiHandler
---@field on_exit? UiHandler ---@field on_exit? UiHandler
---@field on_click? UiHandler ---@field on_click? UiHandler
---@field paint? fun(id: NodeId, x: integer, y: integer, w: integer, h: integer) ---@field paint? fun(id: NodeId, x: integer, y: integer, w: integer, h: integer, clipX: integer, clipY: integer, clipW: integer, clipH: integer)
---@field press_style? boolean False for a widget that paints its own press feedback. ---@field press_style? boolean False for a widget that paints its own press feedback.
---@field scrollX? boolean Children measure unbounded across, and the box pans horizontally.
---@field scrollY? boolean Children measure unbounded down, and the box pans vertically.
---@class UiConfirmSpec ---@class UiConfirmSpec
---@field title string ---@field title string
@@ -42,7 +43,6 @@ local ui = {}
---@field on_cancel? UiHandler ---@field on_cancel? UiHandler
---@field on_outside? UiHandler ---@field on_outside? UiHandler
local THEME_PATH = "/.lua/theme"
local THEMES = { local THEMES = {
light = { background = { 255, 255, 255 }, color = { 0, 0, 0 }, accent = { 0, 120, 255 }, radius = 6 }, light = { background = { 255, 255, 255 }, color = { 0, 0, 0 }, accent = { 0, 120, 255 }, radius = 6 },
dark = { background = { 18, 18, 20 }, color = { 235, 235, 235 }, accent = { 166, 118, 255 }, radius = 6 }, dark = { background = { 18, 18, 20 }, color = { 235, 235, 235 }, accent = { 166, 118, 255 }, radius = 6 },
@@ -55,9 +55,29 @@ local enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {}
local pressStyles = {} local pressStyles = {}
local laidOut = false local laidOut = false
local themeName local themeName
local activeScreen -- One tree per state, built by the function ui.mount() was given. Rebuilding is
-- cheap enough that nothing is retained between screens.
local builder, root, responder, insideResponder, confirming
local inset = 0
local applyPalette local applyPalette
-- Scroll gestures live in the tree, not the app: a drag on any scrollX/scrollY box pans it,
-- a flick coasts, and a child button's tap is suppressed once the drag passes SLOP. C++ owns
-- only the clamped offset (setScroll/getScroll); the momentum and the tap-vs-pan decision are
-- here, keyed by node so every scrollable box gets them for free.
local SCROLL_SLOP = 8
local SCROLL_DECAY = 0.02
local SCROLL_STOP = 8
-- id -> {x=bool, y=bool}: which axes a box pans. Recorded at build, cleared on reset.
local scrollNodes = {}
-- id -> {sx, sy, vx, vy}: pan offset (float, floored into setScroll) and flick velocity.
local scrollState = {}
-- Set of ids still coasting, iterated each draw until they settle.
local flinging = {}
-- Per-gesture bookkeeping: the box being panned once past SLOP, and the raw drag deltas.
local panning, scrollAncestor, dragged
local downX, downY, lastX, lastY, pendingX, pendingY
local function mix(a, b, amount) local function mix(a, b, amount)
local out = {} local out = {}
for i = 1, 3 do for i = 1, 3 do
@@ -67,7 +87,7 @@ local function mix(a, b, amount)
end end
local function color(rgb) local function color(rgb)
return gui.color(rgb[1], rgb[2], rgb[3]) return screen.color(rgb[1], rgb[2], rgb[3])
end end
local function palette(seed) local function palette(seed)
@@ -113,31 +133,33 @@ function ui.setTheme(name)
if not THEMES[name] then if not THEMES[name] then
return nil, "Unknown theme" return nil, "Unknown theme"
end end
local ok, err = fs.writeFile(THEME_PATH, name) local ok, err = screen.setTheme(name)
if not ok then if not ok then
return nil, err return nil, err
end end
loadTheme(name) loadTheme(name)
if activeScreen then if root then
applyPalette(activeScreen.root) applyPalette(root)
gui.clear(ui.theme.background) screen.clear(ui.theme.background)
node.invalidate(activeScreen.root) tree.invalidate(root)
end end
return true return true
end end
local savedTheme = fs.readFile(THEME_PATH, 32) loadTheme(screen.getTheme())
loadTheme(savedTheme and savedTheme:match "^%s*(.-)%s*$" or "light")
local function clearState() local function clearState()
enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {} enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {}
pressStyles = {} pressStyles = {}
end end
node.setPainter(function(id, x, y, w, h) -- The clip is the slice of the node being repainted now, which for a composited repaint is
-- one band. A painter that ignores it still draws correctly; one that culls to it stops
-- redrawing its whole contents once per band it spans.
tree.setPainter(function(id, x, y, w, h, clipX, clipY, clipW, clipH)
local painter = painters[id] local painter = painters[id]
if painter then if painter then
painter(id, x, y, w, h) painter(id, x, y, w, h, clipX, clipY, clipW, clipH)
end end
end) end)
@@ -165,14 +187,16 @@ local function applyStyle(id, spec)
style.background, style.fill, hasStyle = spec.background, spec.background, true style.background, style.fill, hasStyle = spec.background, spec.background, true
end end
if hasStyle then if hasStyle then
node.setStyle(id, style) tree.setStyle(id, style)
end end
end end
local function build(spec, kind) local function build(spec, kind)
spec = spec or {} spec = spec or {}
-- Nodes outside a build would reset the arena under the screen already on the
-- panel, chrome included. Rebuilding is the only way to change one.
if laidOut then if laidOut then
ui.reset() error("build nodes from the function ui.mount() was given, then ui.rebuild()", 3)
end end
local children = {} local children = {}
@@ -183,9 +207,9 @@ local function build(spec, kind)
spec.type = kind spec.type = kind
spec.interactive = spec.on_enter ~= nil or spec.on_exit ~= nil or spec.on_click ~= nil spec.interactive = spec.on_enter ~= nil or spec.on_exit ~= nil or spec.on_click ~= nil
local id = node.create(nil, spec) local id = tree.create(nil, spec)
for _, child in ipairs(children) do for _, child in ipairs(children) do
node.attach(id, child) tree.attach(id, child)
end end
applyStyle(id, spec) applyStyle(id, spec)
@@ -194,6 +218,10 @@ local function build(spec, kind)
clickHandlers[id] = spec.on_click clickHandlers[id] = spec.on_click
painters[id] = spec.paint painters[id] = spec.paint
pressStyles[id] = spec.press_style ~= false pressStyles[id] = spec.press_style ~= false
if spec.scrollX or spec.scrollY then
scrollNodes[id] = { x = spec.scrollX == true, y = spec.scrollY == true }
scrollState[id] = { sx = 0, sy = 0, vx = 0, vy = 0 }
end
return id return id
end end
@@ -212,13 +240,28 @@ end
---@return integer side ---@return integer side
---@return integer columns ---@return integer columns
function ui.cardSide(count, pad, gap, reserve) function ui.cardSide(count, pad, gap, reserve)
local columns = gui.getWidth() >= gui.getHeight() and 3 or 2 local width, height = ui.frame()
local columns = width >= height and 3 or 2
local rows = math.ceil(count / columns) local rows = math.ceil(count / columns)
local byWidth = (gui.getWidth() - 2 * pad - (columns - 1) * gap) // columns local byWidth = (width - 2 * pad - (columns - 1) * gap) // columns
local byHeight = (gui.getHeight() - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows local byHeight = (height - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows
return math.min(byWidth, byHeight), columns return math.min(byWidth, byHeight), columns
end end
---How much of the panel chrome took before the app was built. Set by whatever mounts the
---tree, because layout has not run yet when an app sizes itself.
---@param px integer
function ui.setInset(px)
inset = px
end
---The box the app is built into, which is the panel minus the chrome above it.
---@return integer w
---@return integer h
function ui.frame()
return screen.getWidth(), screen.getHeight() - inset
end
---@param spec UiSpec ---@param spec UiSpec
---@return NodeId ---@return NodeId
function ui.spacer(spec) function ui.spacer(spec)
@@ -241,15 +284,15 @@ end
---@return NodeId ---@return NodeId
function ui.label(text, spec) function ui.label(text, spec)
spec = spec or {} spec = spec or {}
local font, style = spec.font or gui.FONT_UI, spec.style or gui.STYLE_NORMAL local font, style = spec.font or screen.FONT_UI, spec.style or screen.STYLE_NORMAL
if spec.fit and gui.getTextWidth(font, text, style) > spec.fit then if spec.fit and screen.getTextWidth(font, text, style) > spec.fit then
while #text > 1 and gui.getTextWidth(font, text .. "~", style) > spec.fit do while #text > 1 and screen.getTextWidth(font, text .. "~", style) > spec.fit do
text = text:sub(1, -2) text = text:sub(1, -2)
end end
text = text .. "~" text = text .. "~"
end end
spec.w = gui.getTextWidth(font, text, style) spec.w = screen.getTextWidth(font, text, style)
spec.h = gui.getFontHeight(font, style) spec.h = screen.getFontHeight(font, style)
spec.font, spec.fit = font, nil spec.font, spec.fit = font, nil
return ui.text(text, spec) return ui.text(text, spec)
end end
@@ -264,7 +307,7 @@ function ui.button(spec)
spec.label = nil spec.label = nil
local id = build(spec, "button") local id = build(spec, "button")
if label then if label then
node.create(id, { type = "text", label = label, font = font or gui.FONT_UI }) tree.create(id, { type = "text", label = label, font = font or screen.FONT_UI })
end end
return id return id
end end
@@ -278,16 +321,39 @@ end
---@param id NodeId ---@param id NodeId
---@param text string ---@param text string
function ui.setText(id, text) function ui.setText(id, text)
if node.getLabel(id) == text then if tree.getLabel(id) == text then
return return
end end
node.setLabel(id, text) tree.setLabel(id, text)
node.invalidate(id) tree.invalidate(id)
end end
---@param id NodeId ---@param id NodeId
function ui.invalidate(id) function ui.invalidate(id)
node.invalidate(id) tree.invalidate(id)
end
---Pans a scrollable node, clamped to its content. The node is marked dirty, so the next
---ui.draw() repaints it -- there is nothing for the app to draw and no handle to refresh,
---which is the whole point of scrolling in the tree rather than in a painter.
---@param id NodeId
---@param x integer
---@param y integer
function ui.setScroll(id, x, y)
tree.setScroll(id, x, y)
end
---@param id NodeId
---@return integer x, integer y
function ui.getScroll(id)
return tree.getScroll(id)
end
---How far each axis can pan. Zero on an axis whose content already fits.
---@param id NodeId
---@return integer maxX, integer maxY
function ui.getScrollRange(id)
return tree.getScrollRange(id)
end end
---@param spec UiConfirmSpec ---@param spec UiConfirmSpec
@@ -316,7 +382,9 @@ function ui.confirm(spec)
at = { x = 0, y = 0 }, at = { x = 0, y = 0 },
w = "fill", w = "fill",
h = "fill", h = "fill",
capture = true, -- A full-bleed layer painted last: hit-testing returns the topmost node, so taps on the
-- dim area land here and cannot reach the content beneath. on_outside handles them; with
-- none, they die at the root -- which is what makes the dialog modal.
align = "center", align = "center",
justify = "center", justify = "center",
on_click = spec.on_outside, on_click = spec.on_outside,
@@ -325,20 +393,18 @@ function ui.confirm(spec)
end end
function ui.reset() function ui.reset()
node.reset() tree.reset()
clearState() clearState()
laidOut = false laidOut = false
activeScreen = nil root, responder, insideResponder, confirming = nil, nil, nil, nil
scrollNodes, scrollState, flinging = {}, {}, {}
panning, scrollAncestor, dragged = nil, nil, false
end end
---@class UiScreen
local Screen = {}
Screen.__index = Screen
applyPalette = function(root) applyPalette = function(root)
-- The root is the panel background, not a card: no border, so it takes the fast fillRect -- The root is the panel background, not a card: no border, so it takes the fast fillRect
-- path rather than the per-pixel roundRect one. Radius stays so cards inherit it. -- path rather than the per-pixel roundRect one. Radius stays so cards inherit it.
node.setStyle(root, { tree.setStyle(root, {
color = ui.theme.color, color = ui.theme.color,
background = ui.theme.background, background = ui.theme.background,
face = ui.theme.face, face = ui.theme.face,
@@ -346,47 +412,131 @@ applyPalette = function(root)
pressedColor = ui.theme.pressedColor, pressedColor = ui.theme.pressedColor,
focusColor = ui.theme.focusColor, focusColor = ui.theme.focusColor,
radius = ui.theme.radius, radius = ui.theme.radius,
font = gui.FONT_UI, font = screen.FONT_UI,
}) })
end end
---@param root NodeId ---Registers the function that builds the whole tree and shows what it returns.
---@param style? NodeStyle ---@param fn fun(): NodeId
---@return UiScreen function ui.mount(fn)
function ui.screen(root, style) builder = fn
node.setSize(root, "fill", "fill") ui.rebuild()
applyPalette(root)
if style then
node.setStyle(root, style)
end
local screen = setmetatable({ root = root }, Screen)
activeScreen = screen
screen:relayout()
return screen
end end
function Screen:relayout() ---Rebuilds the tree from scratch and repaints. Screens are not retained, so this
local ok, err = node.layout(self.root, 0, 0, gui.getWidth(), gui.getHeight()) ---is how a screen changes, a rotation is answered and a dialog opens.
function ui.rebuild()
ui.reset()
root = builder()
tree.setSize(root, "fill", "fill")
applyPalette(root)
local ok, err = tree.layout(root, 0, 0, screen.getWidth(), screen.getHeight())
if not ok then if not ok then
error(err, 2) error(err, 2)
end end
node.dropScratch() tree.dropScratch()
laidOut = true laidOut = true
gui.clear(ui.theme.background) screen.clear(ui.theme.background)
tree.draw(root)
-- A build allocates a spec table per node and drops them all here, and the next thing an
-- app does may be the one that needs a contiguous WiFi buffer. Collecting now costs a few
-- milliseconds on a screen change nobody can see, and leaves the heap in a known state
-- instead of one that depends on when the incremental GC last ran.
collectgarbage()
end end
function Screen:draw() -- Pushes a node's float offset into the tree, then snaps our copy back to whatever the
node.draw(self.root) -- clamp allowed -- without this the offset runs past the end while the content sits still
-- and the list ignores the first part of the drag back.
local function applyScroll(id, st)
ui.setScroll(id, math.floor(st.sx), math.floor(st.sy))
local cx, cy = ui.getScroll(id)
if cx and cx ~= math.floor(st.sx) then
st.sx, st.vx = cx, 0
end
if cy and cy ~= math.floor(st.sy) then
st.sy, st.vy = cy, 0
end
end
-- Free settle: velocity decays to rest, applied as DECAY^seconds so the glide lasts the
-- same wall time whatever the frame rate. A paged box would instead ease toward the nearest
-- page boundary here -- same gesture, different ending -- so this is the one seam paging adds.
local function settleFree(st, seconds)
st.sx, st.sy = st.sx + st.vx * seconds, st.sy + st.vy * seconds
local keep = SCROLL_DECAY ^ seconds
st.vx, st.vy = st.vx * keep, st.vy * keep
if math.abs(st.vx) < SCROLL_STOP then
st.vx = 0
end
if math.abs(st.vy) < SCROLL_STOP then
st.vy = 0
end
end
-- One GC step a frame, because the collector's default pace is the painter's problem: a
-- band buffer needs a contiguous block, and letting the heap double before a cycle lets
-- garbage take the block the band was going to get. Stepping keeps the sawtooth shallow
-- enough that beginBuffer() keeps succeeding instead of falling back to the panel.
---@param deltaMs? integer Elapsed frame time, used to advance any active pan or flick.
function ui.draw(deltaMs)
local seconds = (deltaMs or 0) / 1000
if seconds > 0 then
if panning then
local st = scrollState[panning]
st.sx, st.sy = st.sx + pendingX, st.sy + pendingY
-- Velocity is measured over the frame the motion arrived in, so a finger that paused
-- before lifting reports zero and does not flick.
st.vx, st.vy = pendingX / seconds, pendingY / seconds
pendingX, pendingY = 0, 0
applyScroll(panning, st)
end
for id in pairs(flinging) do
local st = scrollState[id]
settleFree(st, seconds)
applyScroll(id, st)
if st.vx == 0 and st.vy == 0 then
flinging[id] = nil
end
end
end
if root then
tree.draw(root)
end
collectgarbage "step"
end
-- The nearest ancestor (or self) that pans, or nil. A drag on a child button scrolls the
-- list it sits in, which is why the tap has to yield to the pan rather than the reverse.
local function scrollableAncestor(id)
while id do
if scrollNodes[id] then
return id
end
id = tree.getParent(id)
end
end
-- The nearest ancestor (or self) that handles a press, or nil. tree.hit returns the deepest
-- node by geometry -- a button's text label, say -- so dispatch bubbles up to whoever owns
-- the on_click/on_enter/on_exit, the way a responder chain does.
local function handlerAncestor(id)
while id do
if clickHandlers[id] or enterHandlers[id] or exitHandlers[id] then
return id
end
id = tree.getParent(id)
end
end end
local function inside(id, x, y) local function inside(id, x, y)
local rx, ry, rw, rh = node.getRect(id) local rx, ry, rw, rh = tree.getRect(id)
return x >= rx and x < rx + rw and y >= ry and y < ry + rh return x >= rx and x < rx + rw and y >= ry and y < ry + rh
end end
local function enter(id, x, y) local function enter(id, x, y)
if pressStyles[id] then if pressStyles[id] then
node.setPressed(id, true) tree.setPressed(id, true)
end end
local handler = enterHandlers[id] local handler = enterHandlers[id]
if handler then if handler then
@@ -396,7 +546,7 @@ end
local function exit(id, x, y) local function exit(id, x, y)
if pressStyles[id] then if pressStyles[id] then
node.setPressed(id, false) tree.setPressed(id, false)
end end
local handler = exitHandlers[id] local handler = exitHandlers[id]
if handler then if handler then
@@ -407,40 +557,77 @@ end
---@param x integer ---@param x integer
---@param y integer ---@param y integer
---@return boolean handled ---@return boolean handled
function Screen:down(x, y) function ui.down(x, y)
local focused = node.getFocus() local focused = tree.getFocus()
if focused then if focused then
node.setFocus(nil) tree.setFocus(nil)
local handler = exitHandlers[focused] local handler = exitHandlers[focused]
if handler then if handler then
handler(focused) handler(focused)
end end
end end
local target = node.hit(self.root, x, y) local hitNode = root and tree.hit(root, x, y)
if not target then if not hitNode then
return false return false
end end
self.captured, self.inside = target, true responder, scrollAncestor = handlerAncestor(hitNode), scrollableAncestor(hitNode)
enter(target, x, y) insideResponder, dragged, panning = responder ~= nil, false, nil
return true downX, downY, lastX, lastY = x, y, x, y
pendingX, pendingY = 0, 0
if scrollAncestor then
-- A finger down catches an in-progress glide, the way every touch UI does.
flinging[scrollAncestor] = nil
local st = scrollState[scrollAncestor]
st.vx, st.vy = 0, 0
end
if responder then
enter(responder, x, y)
end
return responder ~= nil or scrollAncestor ~= nil
end end
---@param x integer ---@param x integer
---@param y integer ---@param y integer
---@return boolean handled ---@return boolean handled
function Screen:move(x, y) function ui.move(x, y)
local target = self.captured if not responder and not scrollAncestor then
if not target then
return false return false
end end
local isInside = inside(target, x, y) if scrollAncestor then
if isInside ~= self.inside then local flags = scrollNodes[scrollAncestor]
self.inside = isInside if flags.x then
if isInside then pendingX = pendingX + (lastX - x)
enter(target, x, y) end
else if flags.y then
exit(target, x, y) pendingY = pendingY + (lastY - y)
end
lastX, lastY = x, y
if not dragged then
local past = (flags.x and math.abs(x - downX) > SCROLL_SLOP)
or (flags.y and math.abs(y - downY) > SCROLL_SLOP)
if past then
dragged, panning = true, scrollAncestor
-- The press became a scroll: drop the responder's feedback and keep it from clicking.
if responder and insideResponder then
exit(responder, x, y)
end
insideResponder = false
end
end
if dragged then
return true
end
end
if responder then
local isInside = inside(responder, x, y)
if isInside ~= insideResponder then
insideResponder = isInside
if isInside then
enter(responder, x, y)
else
exit(responder, x, y)
end
end end
end end
return true return true
@@ -449,28 +636,39 @@ end
---@param x integer ---@param x integer
---@param y integer ---@param y integer
---@return boolean handled ---@return boolean handled
function Screen:up(x, y) function ui.up(x, y)
local target = self.captured if not responder and not scrollAncestor then
if not target then
return false return false
end end
local wasActive = self.inside if dragged then
local releasedInside = inside(target, x, y) -- The gesture was a pan: hand any velocity the last frames built to draw(), which coasts
local handler = releasedInside and clickHandlers[target] or nil -- and clamps it. A drag that ended still carries zero velocity, so it simply stops.
self.captured, self.inside = nil, nil if panning then
if wasActive then flinging[panning] = true
exit(target, x, y) end
responder, insideResponder, scrollAncestor, dragged, panning = nil, nil, nil, false, nil
return true
end end
if handler then local target, wasActive = responder, insideResponder
handler(target, x, y) responder, insideResponder, scrollAncestor = nil, nil, nil
if target then
if wasActive then
exit(target, x, y)
end
if inside(target, x, y) then
local handler = clickHandlers[target]
if handler then
handler(target, x, y)
end
end
end end
return true return true
end end
local DIRECTIONS = { up = true, down = true, left = true, right = true } local DIRECTIONS = { up = true, down = true, left = true, right = true }
local function focusFirst(screen) local function focusFirst()
local focused = node.focusFirst(screen.root) local focused = tree.focusFirst(root)
if focused then if focused then
local handler = enterHandlers[focused] local handler = enterHandlers[focused]
if handler then if handler then
@@ -483,7 +681,7 @@ end
---@param name string Button name; directions and confirm are handled. ---@param name string Button name; directions and confirm are handled.
---@param pressed boolean ---@param pressed boolean
---@return boolean handled ---@return boolean handled
function Screen:button(name, pressed) function ui.buttonPress(name, pressed)
if type(pressed) ~= "boolean" then if type(pressed) ~= "boolean" then
error("button state must be boolean", 2) error("button state must be boolean", 2)
end end
@@ -492,12 +690,12 @@ function Screen:button(name, pressed)
if not pressed then if not pressed then
return true return true
end end
local previous = node.getFocus() local previous = tree.getFocus()
if not previous then if not previous then
focusFirst(self) focusFirst()
return true return true
end end
local focused = node.moveFocus(self.root, name) local focused = tree.moveFocus(root, name)
if focused ~= previous then if focused ~= previous then
local leave = exitHandlers[previous] local leave = exitHandlers[previous]
if leave then if leave then
@@ -514,18 +712,18 @@ function Screen:button(name, pressed)
if name ~= "confirm" then if name ~= "confirm" then
return false return false
end end
local focused = node.getFocus() or focusFirst(self) local focused = tree.getFocus() or focusFirst()
if not focused then if not focused then
return false return false
end end
if pressed then if pressed then
node.setPressed(focused, true) tree.setPressed(focused, true)
self.confirming = focused confirming = focused
else else
local target = self.confirming local target = confirming
self.confirming = nil confirming = nil
if target then if target then
node.setPressed(target, false) tree.setPressed(target, false)
local handler = clickHandlers[target] local handler = clickHandlers[target]
if handler then if handler then
handler(target) handler(target)
+275
View File
@@ -0,0 +1,275 @@
local ui = require "ui"
local FONT = screen.FONT_UI
local M = {}
local KEY_W, KEY_H, KEY_GAP, ROW_GAP = 26, 30, 4, 5
local SIDE_W, MODE_W, SPACE_W, OK_W = 40, 54, 174, 58
local PAGES = {
lower = { "qwertyuiop", "asdfghjkl", "zxcvbnm" },
upper = { "QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM" },
numbers = { "1234567890", "-/:;()$&@", ".,?!'" },
symbols = { "[]{}#%^*+=", "_\\|~<>$&@", ".,?!'" },
}
-- Keyed by node id, because the node itself is sixteen bytes in the firmware and holds
-- nothing a keyboard cares about.
local state = {}
local function chars(page, row)
return PAGES[page][row]
end
local function rowWidth(count)
return count * KEY_W + (count - 1) * KEY_GAP
end
local function centeredX(rect, width)
return rect.x + (rect.w - width) // 2
end
local function thirdRow(page, rect)
local value = chars(page, 3)
local width = SIDE_W * 2 + KEY_GAP * 2 + rowWidth(#value)
return value, centeredX(rect, width)
end
-- Pure geometry: which key covers a point, given a page and the rectangle the board was
-- placed in. Kept free of the node tree so the host tests can exercise the maths that
-- actually decides what a tap enters.
function M.keyAt(page, rect, x, y)
local localY = y - rect.y
if localY < 0 then
return
end
local row = localY // (KEY_H + ROW_GAP) + 1
if row > 4 or localY % (KEY_H + ROW_GAP) >= KEY_H then
return
end
if row <= 2 then
local value = chars(page, row)
local localX = x - centeredX(rect, rowWidth(#value))
if localX < 0 then
return
end
local column = localX // (KEY_W + KEY_GAP) + 1
if column <= #value and localX % (KEY_W + KEY_GAP) < KEY_W then
return (row - 1) * 10 + column, "char", value:sub(column, column)
end
return
end
if row == 3 then
local value, start = thirdRow(page, rect)
local localX = x - start
if localX < 0 then
return
end
if localX < SIDE_W then
return 90, (page == "lower" or page == "upper") and "shift" or "symbols"
end
localX = localX - SIDE_W - KEY_GAP
local width = rowWidth(#value)
if localX >= 0 and localX < width then
local column = localX // (KEY_W + KEY_GAP) + 1
if localX % (KEY_W + KEY_GAP) < KEY_W then
return 20 + column, "char", value:sub(column, column)
end
return
end
localX = localX - width - KEY_GAP
if localX >= 0 and localX < SIDE_W then
return 91, "backspace"
end
return
end
local width = MODE_W + SPACE_W + OK_W + KEY_GAP * 2
local localX = x - centeredX(rect, width)
if localX < 0 then
return
end
if localX < MODE_W then
return 100, "mode"
end
localX = localX - MODE_W - KEY_GAP
if localX >= 0 and localX < SPACE_W then
return 101, "space"
end
localX = localX - SPACE_W - KEY_GAP
if localX >= 0 and localX < OK_W then
return 102, "submit"
end
end
local function drawArrow(x, y, width, color, down)
local middle = x + width // 2
if down then
screen.drawLine(middle, y + 8, middle, y + 20, color)
screen.drawLine(middle - 5, y + 15, middle, y + 20, color)
screen.drawLine(middle, y + 20, middle + 5, y + 15, color)
else
screen.drawLine(middle, y + 9, middle, y + 21, color)
screen.drawLine(middle - 5, y + 14, middle, y + 9, color)
screen.drawLine(middle, y + 9, middle + 5, y + 14, color)
end
end
local function drawKey(id, label, x, y, width, pressed)
local theme = ui.theme
local face = pressed and theme.pressedFace or theme.face
local color = pressed and theme.pressedColor or theme.color
screen.roundRect(x, y, width, KEY_H, theme.radius, theme.background, face, nil, color)
if label == "shift" then
drawArrow(x, y, width, color, state[id].page == "upper")
else
screen.drawText(
FONT,
x + (width - screen.getTextWidth(FONT, label)) // 2,
y + (KEY_H - screen.getFontHeight(FONT)) // 2,
label,
color,
nil,
face
)
end
end
-- Walks every key, handing each one to `visit`. Painting the whole board and repainting a
-- single key are the same traversal, so a key's position is defined in one place. Pure,
-- for the same reason keyAt is.
function M.eachKey(page, rect, visit)
local y = rect.y
for row = 1, 2 do
local value = chars(page, row)
local x = centeredX(rect, rowWidth(#value))
for column = 1, #value do
visit((row - 1) * 10 + column, value:sub(column, column), x, y, KEY_W)
x = x + KEY_W + KEY_GAP
end
y = y + KEY_H + ROW_GAP
end
local value, x = thirdRow(page, rect)
visit(90, (page == "lower" or page == "upper") and "shift" or (page == "numbers" and "#+=" or "123"), x, y, SIDE_W)
x = x + SIDE_W + KEY_GAP
for column = 1, #value do
visit(20 + column, value:sub(column, column), x, y, KEY_W)
x = x + KEY_W + KEY_GAP
end
visit(91, "<-", x, y, SIDE_W)
y = y + KEY_H + ROW_GAP
local width = MODE_W + SPACE_W + OK_W + KEY_GAP * 2
x = centeredX(rect, width)
visit(100, (page == "lower" or page == "upper") and "123" or "ABC", x, y, MODE_W)
x = x + MODE_W + KEY_GAP
visit(101, "space", x, y, SPACE_W)
x = x + SPACE_W + KEY_GAP
visit(102, "OK", x, y, OK_W)
end
local function rectOf(id)
local x, y, w, h = tree.getRect(id)
return { x = x, y = y, w = w, h = h }
end
local function paint(id)
M.eachKey(state[id].page, rectOf(id), function(key, label, x, y, width)
drawKey(id, label, x, y, width, false)
end)
end
-- Repaints one key in place. The keyboard is a single node, so there is no parent to
-- clear and nothing else on screen can have moved.
local function drawOneKey(id, target, pressed)
M.eachKey(state[id].page, rectOf(id), function(key, label, x, y, width)
if key == target then
drawKey(id, label, x, y, width, pressed)
end
end)
end
local function down(id, x, y)
local key = M.keyAt(state[id].page, rectOf(id), x, y)
state[id].activeKey = key
if key then
drawOneKey(id, key, true)
end
end
-- The release repaints the key but keeps activeKey, because ui fires on_exit before
-- on_click and the click still has to know which key the press started on.
local function unpress(id)
local key = state[id].activeKey
if key then
drawOneKey(id, key, false)
end
end
local function press(id, x, y)
local st = state[id]
local key, action, char = M.keyAt(st.page, rectOf(id), x, y)
if not key or key ~= st.activeKey then
return
end
st.activeKey = nil
local function changed()
if st.on_change then
st.on_change(st.value)
end
end
if action == "char" then
if #st.value < st.max_length then
st.value = st.value .. char
changed()
end
elseif action == "shift" then
st.page = st.page == "lower" and "upper" or "lower"
tree.invalidate(id)
elseif action == "symbols" then
st.page = st.page == "numbers" and "symbols" or "numbers"
tree.invalidate(id)
elseif action == "mode" then
st.page = (st.page == "lower" or st.page == "upper") and "numbers" or "lower"
tree.invalidate(id)
elseif action == "backspace" then
st.value = st.value:sub(1, -2)
changed()
elseif action == "space" then
if #st.value < st.max_length then
st.value = st.value .. " "
changed()
end
elseif action == "submit" and st.on_submit then
st.on_submit(st.value)
end
end
-- One custom-painted node keeps a full keyboard off the heap: ordinary ui.button keys
-- would add dozens of nodes and their styles while wifi is already holding buffers.
function M.new(spec)
spec = spec or {}
local id = ui.custom {
h = 4 * KEY_H + 3 * ROW_GAP,
paint = paint,
on_enter = down,
on_click = press,
on_exit = unpress,
press_style = false, -- a key highlights itself; the node never does
}
state[id] = {
value = spec.value or "",
max_length = spec.max_length or 64,
page = "lower",
on_change = spec.on_change,
on_submit = spec.on_submit,
}
return id
end
return M
+95
View File
@@ -0,0 +1,95 @@
-- A message that slides up from the bottom edge, holds, then slides back down.
--
-- The card sits inside a scroll container of its own height whose content is twice that:
-- an empty strip above the card. Sliding is therefore ui.setScroll() on the tree already
-- laid out -- a delta on stored coordinates -- rather than a rebuild a frame, and the
-- container scissors the half of the card that has not arrived yet.
--
-- Whatever mounts the tree includes toast.node() and calls toast.draw(deltaMs); show()
-- rebuilds so the node exists, and hide() rebuilds to drop it.
local ui = require "ui"
local M = {}
local HEIGHT = 44
local MARGIN = 12
local SLIDE_MS = 180
local text, box
-- nil, or "in" / "hold" / "out" with the milliseconds spent in it.
local phase, elapsed, holdMs
---@param message string
---@param durationMs? integer How long the card holds before it slides away.
function M.show(message, durationMs)
text, holdMs = message, durationMs or 1500
phase, elapsed = "in", 0
ui.rebuild()
end
function M.hide()
if phase then
phase, text, box = nil, nil, nil
ui.rebuild()
end
end
---@return NodeId|nil
function M.node()
if not phase then
return nil
end
box = ui.box {
at = { x = MARGIN, y = screen.getHeight() - HEIGHT - MARGIN },
w = screen.getWidth() - 2 * MARGIN,
h = HEIGHT,
scrollY = true,
ui.box {
w = "fill",
ui.spacer { w = "fill", h = HEIGHT },
ui.box {
w = "fill",
h = HEIGHT,
pad = 12,
align = "center",
justify = "center",
background = ui.theme.face,
border = ui.theme.muted,
ui.text(text),
},
},
}
return box
end
local function progress()
if phase == "in" then
return elapsed / SLIDE_MS
elseif phase == "out" then
return 1 - elapsed / SLIDE_MS
end
return 1
end
---Advances the slide. Safe to call every frame whether or not a toast is up.
---@param deltaMs integer
function M.draw(deltaMs)
if not phase then
return
end
elapsed = elapsed + deltaMs
if phase == "in" and elapsed >= SLIDE_MS then
phase, elapsed = "hold", 0
elseif phase == "hold" and elapsed >= holdMs then
phase, elapsed = "out", 0
elseif phase == "out" and elapsed >= SLIDE_MS then
M.hide()
return
end
if box then
ui.setScroll(box, 0, math.floor(HEIGHT * progress()))
end
end
return M
+3 -3
View File
@@ -3,7 +3,7 @@ package.path = "./lua/lib/?.lua;" .. package.path
local drawn = {} local drawn = {}
local roles = { "confirm", "back", "right" } local roles = { "confirm", "back", "right" }
gui = { screen = {
FONT_SMALL = 0, FONT_SMALL = 0,
STYLE_NORMAL = 0, STYLE_NORMAL = 0,
color = function(r, g, b) color = function(r, g, b)
@@ -29,8 +29,8 @@ gui = {
end, end,
} }
input = { buttons = {
getButtons = function() getAll = function()
return roles return roles
end, end,
} }
+74 -43
View File
@@ -17,9 +17,17 @@ fs = {
end, end,
} }
local savedTheme = "light"
local frameWidth, frameHeight = 320, 480 local frameWidth, frameHeight = 320, 480
gui = { screen = {
getTheme = function()
return savedTheme
end,
setTheme = function(name)
savedTheme = name
return true
end,
FONT_SMALL = 0, FONT_SMALL = 0,
FONT_UI = 1, FONT_UI = 1,
FONT_BODY = 2, FONT_BODY = 2,
@@ -59,7 +67,7 @@ local function interactiveNodes()
return result return result
end end
node = { tree = {
reset = function() reset = function()
nodes, focus, buttonCount = {}, nil, 0 nodes, focus, buttonCount = {}, nil, 0
end, end,
@@ -84,6 +92,9 @@ node = {
attach = function(parent, child) attach = function(parent, child)
nodes[child].parent = parent nodes[child].parent = parent
end, end,
getParent = function(id)
return nodes[id].parent
end,
setSize = function() end, setSize = function() end,
setStyle = function(id, style) setStyle = function(id, style)
for key, value in pairs(style) do for key, value in pairs(style) do
@@ -166,7 +177,7 @@ assert(table.concat(ui.themeNames(), ",") == "dark,light,mono")
local ok, err = ui.setTheme "missing" local ok, err = ui.setTheme "missing"
assert(ok == nil and err == "Unknown theme") assert(ok == nil and err == "Unknown theme")
assert(ui.setTheme "dark" == true) assert(ui.setTheme "dark" == true)
assert(files["/.lua/theme"] == "dark" and ui.getTheme() == "dark") assert(savedTheme == "dark" and ui.getTheme() == "dark")
local events = {} local events = {}
local function handler(name) local function handler(name)
@@ -175,28 +186,31 @@ local function handler(name)
end end
end end
local first = ui.button { local first, second
label = "one", ui.mount(function()
on_enter = handler "enter", first = ui.button {
on_exit = handler "exit", label = "one",
on_click = handler "click", on_enter = handler "enter",
} on_exit = handler "exit",
local second = ui.button { on_click = handler "click",
label = "two", }
on_enter = handler "enter", second = ui.button {
on_exit = handler "exit", label = "two",
on_click = handler "click", on_enter = handler "enter",
} on_exit = handler "exit",
local screen = ui.screen(ui.box { row = true, first, second }) on_click = handler "click",
}
return ui.box { row = true, first, second }
end)
assert(screen:down(10, 10)) assert(ui.down(10, 10))
assert(node.isPressed(first)) assert(tree.isPressed(first))
assert(screen:move(95, 10)) assert(ui.move(95, 10))
assert(not node.isPressed(first)) assert(not tree.isPressed(first))
assert(screen:move(10, 10)) assert(ui.move(10, 10))
assert(node.isPressed(first)) assert(tree.isPressed(first))
assert(screen:up(10, 10)) assert(ui.up(10, 10))
assert(not node.isPressed(first)) assert(not tree.isPressed(first))
local expectedTouch = { "enter", "exit", "enter", "exit", "click" } local expectedTouch = { "enter", "exit", "enter", "exit", "click" }
for index, name in ipairs(expectedTouch) do for index, name in ipairs(expectedTouch) do
@@ -207,16 +221,16 @@ for index, name in ipairs(expectedTouch) do
end end
events = {} events = {}
assert(screen:button("right", true)) assert(ui.buttonPress("right", true))
assert(screen:button("right", false)) assert(ui.buttonPress("right", false))
assert(node.getFocus() == first) assert(tree.getFocus() == first)
assert(screen:button("right", true)) assert(ui.buttonPress("right", true))
assert(node.getFocus() == second) assert(tree.getFocus() == second)
assert(screen:button("confirm", true)) assert(ui.buttonPress("confirm", true))
assert(node.isPressed(second)) assert(tree.isPressed(second))
assert(screen:button("confirm", false)) assert(ui.buttonPress("confirm", false))
assert(not node.isPressed(second)) assert(not tree.isPressed(second))
assert(screen:button("back", true) == false) assert(ui.buttonPress("back", true) == false)
local expectedButtons = { local expectedButtons = {
{ "enter", first }, { "enter", first },
@@ -235,7 +249,13 @@ assert(ui.setTheme "mono" == true)
assert(ui.getTheme() == "mono" and #invalidated == before + 1) assert(ui.getTheme() == "mono" and #invalidated == before + 1)
assert(cleared == ui.theme.background) assert(cleared == ui.theme.background)
screen:draw() ui.draw()
-- The inset chrome took comes off the height budget before anything else.
ui.setInset(44)
assert(select(2, ui.frame()) == 436, "the frame is the panel minus the chrome")
assert(select(1, ui.cardSide(5, 12, 8)) == 132, "a grid fits the app's box, not the panel")
ui.setInset(0)
-- 320x480 portrait: two columns, and the reserve comes off the height budget. -- 320x480 portrait: two columns, and the reserve comes off the height budget.
local side, columns = ui.cardSide(5, 12, 8) local side, columns = ui.cardSide(5, 12, 8)
@@ -251,24 +271,35 @@ frameWidth, frameHeight = 320, 480
-- still is. Which node was hit is geometry, so the test names the target directly. -- still is. Which node was hit is geometry, so the test names the target directly.
ui.reset() ui.reset()
local pressedCalls = {} local pressedCalls = {}
node.setPressed = function(_, on) tree.setPressed = function(_, on)
pressedCalls[#pressedCalls + 1] = on pressedCalls[#pressedCalls + 1] = on
end end
local own = ui.custom { h = 20, press_style = false, on_click = function() end } local own, styled
local styled = ui.button { h = 20, label = "ok", on_click = function() end } ui.mount(function()
local board = ui.screen(ui.box { own, styled }) own = ui.custom { h = 20, press_style = false, on_click = function() end }
styled = ui.button { h = 20, label = "ok", on_click = function() end }
return ui.box { own, styled }
end)
local target local target
node.hit = function() tree.hit = function()
return target return target
end end
target = own target = own
board:down(0, 0) ui.down(0, 0)
board:up(0, 0) ui.up(0, 0)
assert(#pressedCalls == 0, "a self-painting widget is not styled on press") assert(#pressedCalls == 0, "a self-painting widget is not styled on press")
target = styled target = styled
board:down(0, 0) ui.down(0, 0)
assert(pressedCalls[1] == true, "an ordinary widget still gets its pressed style") assert(pressedCalls[1] == true, "an ordinary widget still gets its pressed style")
-- Building outside a rebuild would reset the arena under the screen on the panel.
local built = pcall(ui.button, { label = "stray" })
assert(not built, "a node built after layout is refused")
local rebuilt = false
ui.rebuild()
rebuilt = true
assert(rebuilt and ui.down(0, 0), "a rebuild replaces the screen and keeps dispatch live")
print "ok" print "ok"
+120 -13
View File
@@ -32,10 +32,11 @@ enum Type : uint8_t { BOX, TEXT, BUTTON, CUSTOM };
enum Flag : uint8_t { enum Flag : uint8_t {
ROW = 1 << 0, // main axis is horizontal ROW = 1 << 0, // main axis is horizontal
CAPTURE = 1 << 1, // swallows the taps its children missed
INTERACTIVE = 1 << 2, // has an on_press INTERACTIVE = 1 << 2, // has an on_press
DIRTY = 1 << 3, DIRTY = 1 << 3,
PRESSED = 1 << 4, PRESSED = 1 << 4,
SCROLL_X = 1 << 5, // children measure unbounded across, and pan horizontally
SCROLL_Y = 1 << 6,
}; };
enum SizeMode : uint8_t { AUTO, PX, FRACTION, FILL }; enum SizeMode : uint8_t { AUTO, PX, FRACTION, FILL };
@@ -118,6 +119,15 @@ struct Spec {
uint16_t last = NONE; // tail of the child list, so append is not a walk uint16_t last = NONE; // tail of the child list, so append is not a walk
}; };
// A scroll container's pan offset and the size of what it holds. Sparse like
// styles, because a screen has one or two of these and Node has no room: the
// offsets are what setScroll() clamps against, and the content size is the only
// thing measure() learns that place() would otherwise throw away.
struct Scroll {
int16_t x = 0, y = 0;
int16_t contentW = 0, contentH = 0;
};
// Resistive panels land a few pixels off, so a hit box is larger than what was // Resistive panels land a few pixels off, so a hit box is larger than what was
// painted. // painted.
constexpr int SLOP = 4; constexpr int SLOP = 4;
@@ -145,6 +155,7 @@ public:
labelAt.clear(); labelAt.clear();
labels.clear(); labels.clear();
styles.clear(); styles.clear();
scrollState.clear();
error = nullptr; error = nullptr;
} }
@@ -192,8 +203,10 @@ public:
return error == nullptr; return error == nullptr;
} }
// Deepest interactive node wins, so a tappable child beats its tappable // Deepest node by geometry wins, so a child beats its parent and a later sibling
// parent. The list is singly linked, so "last match walking forward" stands // beats an earlier one it overlaps. Whether that node responds to a press or a pan is
// dispatch's business, which bubbles up from here -- hit-testing is not fused with who
// handles the gesture. The list is singly linked, so "last match walking forward" stands
// in for "first match walking backward"; they name the same node. // in for "first match walking backward"; they name the same node.
uint16_t hit(uint16_t id, int px, int py) const { uint16_t hit(uint16_t id, int px, int py) const {
const Node& n = nodes[id]; const Node& n = nodes[id];
@@ -210,9 +223,65 @@ public:
} }
if (found != NONE) if (found != NONE)
return found; return found;
if (n.flags & CAPTURE) return id;
return id; }
return (n.flags & INTERACTIVE) ? id : NONE;
bool scrolls(uint16_t id) const {
return (nodes[id].flags & (SCROLL_X | SCROLL_Y)) != 0;
}
const Scroll* scrollOf(uint16_t id) const {
for (size_t at = 0; at < scrollState.size(); at++) {
if (scrollState[at].first == id)
return &scrollState[at].second;
}
return nullptr;
}
// How far each axis can pan before the content's far edge reaches the box.
void scrollRange(uint16_t id, int& maxX, int& maxY) const {
maxX = maxY = 0;
const Scroll* s = scrollOf(id);
if (!s)
return;
if (nodes[id].flags & SCROLL_X)
maxX = s->contentW - nodes[id].w;
if (nodes[id].flags & SCROLL_Y)
maxY = s->contentH - nodes[id].h;
if (maxX < 0)
maxX = 0;
if (maxY < 0)
maxY = 0;
}
// Pans the subtree, clamped to the content. Applied as a delta to the stored
// coordinates rather than by placing again: place() reads Spec, which
// dropScratch() has already thrown away, and shifting keeps x/y in screen
// space so hit testing, getRect and every paint routine stay unchanged.
void setScroll(uint16_t id, int x, int y) {
if (!scrolls(id))
return;
Scroll* state = mutableScroll(id);
if (!state)
return;
int maxX = 0, maxY = 0;
scrollRange(id, maxX, maxY);
if (x < 0)
x = 0;
if (y < 0)
y = 0;
if (x > maxX)
x = maxX;
if (y > maxY)
y = maxY;
const int dx = x - state->x, dy = y - state->y;
if (dx == 0 && dy == 0)
return;
state->x = static_cast<int16_t>(x);
state->y = static_cast<int16_t>(y);
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next)
shift(c, -dx, -dy);
nodes[id].flags |= DIRTY;
} }
// Layout inputs are dead once place() has run. Callers drop them here rather // Layout inputs are dead once place() has run. Callers drop them here rather
@@ -302,6 +371,25 @@ private:
std::vector<uint16_t> labelAt; std::vector<uint16_t> labelAt;
std::vector<char> labels; std::vector<char> labels;
std::vector<std::pair<uint16_t, Style>> styles; std::vector<std::pair<uint16_t, Style>> styles;
// Linear rather than the sorted search styles use: a screen has one or two
// scroll containers, so the binary search would cost more than the scan.
std::vector<std::pair<uint16_t, Scroll>> scrollState;
Scroll* mutableScroll(uint16_t id) {
for (size_t at = 0; at < scrollState.size(); at++) {
if (scrollState[at].first == id)
return &scrollState[at].second;
}
scrollState.push_back(std::make_pair(id, Scroll()));
return &scrollState.back().second;
}
void shift(uint16_t id, int dx, int dy) {
nodes[id].x = static_cast<int16_t>(nodes[id].x + dx);
nodes[id].y = static_cast<int16_t>(nodes[id].y + dy);
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next)
shift(c, dx, dy);
}
void fail(const char* message) { void fail(const char* message) {
if (!error) if (!error)
@@ -345,6 +433,22 @@ private:
// by its content cannot tell a child what fraction of it to take. // by its content cannot tell a child what fraction of it to take.
int innerH = h != UNKNOWN ? h - s.padT - s.padB : UNKNOWN; int innerH = h != UNKNOWN ? h - s.padT - s.padB : UNKNOWN;
// A scrolled axis is unbounded for the children, so they take their natural
// size and the content is free to overflow the box. The box itself still
// needs a size of its own on that axis -- one derived from the content
// would grow to fit it and never scroll.
const uint8_t scrollFlags = nodes[id].flags & (SCROLL_X | SCROLL_Y);
if (scrollFlags & SCROLL_X) {
if (w == UNKNOWN)
fail("scroll-x needs a width");
innerW = UNKNOWN;
}
if (scrollFlags & SCROLL_Y) {
if (h == UNKNOWN)
fail("scroll-y needs a height");
innerH = UNKNOWN;
}
int main = 0, cross = 0, count = 0; int main = 0, cross = 0, count = 0;
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next) { for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next) {
measure(c, innerW, innerH); measure(c, innerW, innerH);
@@ -373,16 +477,19 @@ private:
cross = row ? s.intrinsicH : s.intrinsicW; cross = row ? s.intrinsicH : s.intrinsicW;
} }
Node& self = nodes[id];
int along = main + (row ? s.padL + s.padR : s.padT + s.padB); int along = main + (row ? s.padL + s.padR : s.padT + s.padB);
int across = cross + (row ? s.padT + s.padB : s.padL + s.padR); int across = cross + (row ? s.padT + s.padB : s.padL + s.padR);
if (row) { const int contentW = row ? along : across;
self.w = static_cast<int16_t>(w != UNKNOWN ? w : along); const int contentH = row ? across : along;
self.h = static_cast<int16_t>(h != UNKNOWN ? h : across); if (scrollFlags) {
} else { Scroll* state = mutableScroll(id);
self.w = static_cast<int16_t>(w != UNKNOWN ? w : across); state->contentW = static_cast<int16_t>(contentW);
self.h = static_cast<int16_t>(h != UNKNOWN ? h : along); state->contentH = static_cast<int16_t>(contentH);
} }
Node& self = nodes[id];
self.w = static_cast<int16_t>(w != UNKNOWN ? w : contentW);
self.h = static_cast<int16_t>(h != UNKNOWN ? h : contentH);
} }
void place(uint16_t id, int x, int y, int w, int h) { void place(uint16_t id, int x, int y, int w, int h) {
+59 -16
View File
@@ -35,15 +35,6 @@ struct MemoryInfo {
int32_t largestFreeBlock; int32_t largestFreeBlock;
}; };
class SettingsProvider {
public:
virtual ~SettingsProvider() = default;
virtual int32_t rotation() const = 0;
virtual Status setRotation(int32_t degrees) = 0;
virtual std::string timezone() const = 0;
virtual Status setTimezone(const std::string& timezone) = 0;
};
// Only what the firmware alone can answer. App identity, titles, data paths, // Only what the firmware alone can answer. App identity, titles, data paths,
// feature reporting and navigation are the runtime's, because it owns app // feature reporting and navigation are the runtime's, because it owns app
// loading and knows which providers exist. // loading and knows which providers exist.
@@ -53,6 +44,8 @@ public:
virtual int32_t millis() const = 0; virtual int32_t millis() const = 0;
virtual MemoryInfo memory() const = 0; virtual MemoryInfo memory() const = 0;
virtual bool isClockSynced() const = 0; virtual bool isClockSynced() const = 0;
virtual std::string timezone() const = 0;
virtual Status setTimezone(const std::string& timezone) = 0;
}; };
// Scripts are streamed rather than slurped: a whole module in one buffer needs // Scripts are streamed rather than slurped: a whole module in one buffer needs
@@ -111,7 +104,13 @@ public:
virtual int32_t width() const = 0; virtual int32_t width() const = 0;
virtual int32_t height() const = 0; virtual int32_t height() const = 0;
virtual int32_t rotation() const = 0; virtual int32_t rotation() const = 0;
virtual void setRotation(int32_t degrees) = 0; // Applies the rotation and persists it, so the panel comes back the way the
// user left it with no second call to forget.
virtual Status setRotation(int32_t degrees) = 0;
// Only the name of a palette; the colours themselves live in Lua, so the
// firmware can read the saved theme before a lua_State exists.
virtual std::string theme() const = 0;
virtual Status setTheme(const std::string& theme) = 0;
virtual int32_t color(int32_t r, int32_t g, int32_t b) const = 0; virtual int32_t color(int32_t r, int32_t g, int32_t b) const = 0;
virtual void clear(int32_t color) = 0; virtual void clear(int32_t color) = 0;
virtual void fillRect(int32_t x, int32_t y, int32_t w, int32_t h, virtual void fillRect(int32_t x, int32_t y, int32_t w, int32_t h,
@@ -132,15 +131,42 @@ public:
virtual void roundRect(int32_t x, int32_t y, int32_t w, int32_t h, virtual void roundRect(int32_t x, int32_t y, int32_t w, int32_t h,
int32_t radius, int32_t background, const int32_t* top, int32_t radius, int32_t background, const int32_t* top,
const int32_t* bottom, const int32_t* border) = 0; const int32_t* bottom, const int32_t* border) = 0;
// Hands the app the whole panel, including whatever chrome the firmware
// paints.
virtual void setFullscreen(bool on) = 0;
// Applies everything drawn since the last commit. The runtime supplies only // Applies everything drawn since the last commit. The runtime supplies only
// the timing -- the end of a callback batch -- because that is the one fact a // the timing -- the end of a callback batch -- because that is the one fact a
// driver cannot know; which region to touch, which waveform, and whether to // driver cannot know; which region to touch, which waveform, and whether to
// clean up ghosting all stay here. A live LCD has nothing pending and does // clean up ghosting all stay here. A live LCD has nothing pending and does
// nothing. // nothing.
virtual void commit() = 0; virtual void commit() = 0;
// Offscreen band for composited repaints. beginBuffer opens a RAM surface
// covering the screen rectangle (x, y, w, h); every subsequent draw keeps its
// screen coordinates (the driver translates into the band) and clips to it,
// so a caller re-walks the same tree per band without renumbering anything.
// present() blits the band back to (x, y) and returns drawing to the panel.
// Re-rasterizing into RAM and pushing once collapses a per-primitive bus
// transaction storm into a streamed write, which is what direct-to-panel
// drawing cannot make smooth. A driver with no spare RAM (or an e-ink panel
// that gains nothing) refuses the band, so the painter draws to the panel.
virtual bool beginBuffer(int32_t x, int32_t y, int32_t w, int32_t h) {
(void)x;
(void)y;
(void)w;
(void)h;
return false;
}
virtual void present() {}
// Clips every draw to this screen rectangle until clearClip(). The painter
// wraps a custom node in its own box, because a node owns its box and nothing
// else's: a band is usually taller than the node it covers, so a painter that
// draws outside itself -- a list row half scrolled off the top -- would
// otherwise land on whatever else that band covers. Not clipping is only safe
// while no custom node ever overdraws.
virtual void setClip(int32_t x, int32_t y, int32_t w, int32_t h) {
(void)x;
(void)y;
(void)w;
(void)h;
}
virtual void clearClip() {}
virtual void fillPolygon(const int32_t* xs, const int32_t* ys, size_t count, virtual void fillPolygon(const int32_t* xs, const int32_t* ys, size_t count,
int32_t color) = 0; int32_t color) = 0;
// Null coordinates centre the image; null bounds fall back to the panel size. // Null coordinates centre the image; null bounds fall back to the panel size.
@@ -220,10 +246,24 @@ public:
virtual Status forget() = 0; virtual Status forget() = 0;
}; };
struct BleDevice { // One sighting of an advertising device, coalesced to its latest advert. The
std::string name; // payload is the raw advertisement bytes; parsing the AD structures (and any
// beacon format inside them) is the caller's job, so a new sensor format never
// touches this seam.
struct BleObservation {
std::string address; std::string address;
std::string name;
int32_t rssi; int32_t rssi;
std::string payload;
int32_t lastSeenMs;
};
// Kept adverts must carry one of these service-data UUIDs or one of these
// manufacturer ids; an empty filter keeps everything. Applied before an advert
// is buffered, so unwanted devices never occupy a slot.
struct BleFilter {
std::vector<std::string> services;
std::vector<int32_t> manufacturers;
}; };
class BleProvider { class BleProvider {
@@ -232,7 +272,10 @@ public:
virtual Status init(const std::string* name) = 0; virtual Status init(const std::string* name) = 0;
virtual void deinit() = 0; virtual void deinit() = 0;
virtual bool isInitialized() const = 0; virtual bool isInitialized() const = 0;
virtual Status scan(int32_t durationMs, std::vector<BleDevice>& devices) = 0; virtual Status observe(const BleFilter& filter) = 0;
virtual void unobserve() = 0;
virtual bool isObserving() const = 0;
virtual void observed(std::vector<BleObservation>& out) = 0;
virtual Status connect(const std::string& address) = 0; virtual Status connect(const std::string& address) = 0;
virtual void disconnect() = 0; virtual void disconnect() = 0;
virtual bool isConnected() const = 0; virtual bool isConnected() const = 0;
+43 -52
View File
@@ -15,36 +15,38 @@ namespace esp32lua {
// build. // build.
constexpr int32_t API_VERSION = 1; constexpr int32_t API_VERSION = 1;
// Where the runtime looks for apps, their data, and shared modules. // The path a firmware boots. Nothing else here knows it: startApp() takes
struct Paths { // whatever path it is given, and where apps live, where their data goes and
std::string apps = "/.lua/apps"; // what chrome surrounds them are decided by the Lua it loads.
std::string data = "/.lua/data"; constexpr const char* MAIN_PATH = "/.lua/main.lua";
std::string lib = "/.lua/lib";
// Where sys.back() lands once history is empty. It is an app like any other.
std::string home = "Home";
};
// Firmware supplies every core provider; a null feature provider is how // Firmware supplies every core provider; a null feature provider is how
// sys.hasFeature() answers false, and its namespace additions are simply never // sys.hasFeature() answers false, and its namespace additions are simply never
// registered. // registered.
struct Providers { struct Providers {
LogProvider* log = nullptr; LogProvider* log = nullptr;
SettingsProvider* settings = nullptr;
SysProvider* sys = nullptr; SysProvider* sys = nullptr;
FsProvider* fs = nullptr; FsProvider* fs = nullptr;
GuiProvider* gui = nullptr;
HttpProvider* http = nullptr; HttpProvider* http = nullptr;
TimerProvider* timer = nullptr; TimerProvider* timer = nullptr;
WifiProvider* wifi = nullptr; WifiProvider* wifi = nullptr;
BleProvider* ble = nullptr; BleProvider* ble = nullptr;
GuiProvider* gui = nullptr;
TouchProvider* touch = nullptr; TouchProvider* touch = nullptr;
ButtonsProvider* buttons = nullptr; ButtonsProvider* buttons = nullptr;
}; };
// The arguments a launch carries cross the teardown as JSON, because the table
// they came from dies with the state that built it. Encoding raises, so an app
// that passes a function sees the error at its own sys.startApp() call;
// decoding cannot, because by then there is no app to report it to.
std::string encodeJson(lua_State* state, int index);
bool decodeJson(lua_State* state, const std::string& json);
class Runtime { class Runtime {
public: public:
explicit Runtime(const Providers& providers, const Paths& paths = Paths()); explicit Runtime(const Providers& providers);
~Runtime(); ~Runtime();
Runtime(const Runtime&) = delete; Runtime(const Runtime&) = delete;
@@ -55,38 +57,27 @@ public:
void close(); void close();
lua_State* state() const { return state_; } lua_State* state() const { return state_; }
// Replaces the running app with a fresh lua_State, loads // Replaces the running app with a fresh lua_State, loads the path, and hands
// <apps>/<path>/main.lua, and calls init(arg). A failure leaves no app // the table it returns its arguments through start(args). A failure leaves no
// running rather than a half-built one. // app running rather than a half-built one. `argsJson` is the JSON a previous
// state encoded, and is the only thing that crosses the teardown.
bool startApp(const std::string& path, bool startApp(const std::string& path,
const std::string& arg = std::string()); const std::string& argsJson = std::string());
bool hasApp() const { return !appPath_.empty(); } bool hasApp() const { return !appPath_.empty(); }
// The app-relative route, its immutable first component, and the title the // The path that was loaded, which is all the runtime knows about an app.
// app chose.
const std::string& appPath() const { return appPath_; } const std::string& appPath() const { return appPath_; }
std::string appId() const;
std::string appDataPath() const;
const std::string& appTitle() const { return appTitle_; }
void setAppTitle(const std::string& title) { appTitle_ = title; }
bool hasFeature(const std::string& feature) const; bool hasFeature(const std::string& feature) const;
// sys.launch/replace/back record intent and return; swapping the lua_State // sys.startApp records intent and returns; swapping the lua_State inside a
// inside a callback would free the VM that is still executing. The firmware // callback would free the VM that is still executing. The firmware applies it
// applies it between batches. // between batches.
void requestLaunch(const std::string& path, const std::string& arg, void requestStart(const std::string& path, const std::string& argsJson);
bool replace); bool hasPendingNavigation() const { return pending_.pending; }
void requestBack(); // Loads whatever was requested. False means the app failed to start, in which
bool hasPendingNavigation() const { return pending_.kind != Pending::None; } // case no app is running.
// Whether sys.back() would return somewhere rather than land on the launcher,
// which is what firmware chrome needs to decide whether to offer a back
// control.
bool canGoBack() const { return !history_.empty(); }
// Loads whatever was requested. False means the app failed to start or
// history ran out at the launcher, in which case no app is running.
bool applyPendingNavigation(); bool applyPendingNavigation();
LogProvider& log() const { return *providers_.log; } LogProvider& log() const { return *providers_.log; }
SettingsProvider& settings() const { return *providers_.settings; }
SysProvider& sys() const { return *providers_.sys; } SysProvider& sys() const { return *providers_.sys; }
FsProvider& fs() const { return *providers_.fs; } FsProvider& fs() const { return *providers_.fs; }
GuiProvider& gui() const { return *providers_.gui; } GuiProvider& gui() const { return *providers_.gui; }
@@ -99,11 +90,11 @@ public:
ui::Tree& tree() { return tree_; } ui::Tree& tree() { return tree_; }
// Entry points into the app. The firmware decides whether an event reaches // Entry points into main.lua, which forwards whatever the app it mounted
// the app at all -- jitter, chrome and debouncing are its business -- and the // defines. The firmware decides whether an event happens at all -- jitter and
// runtime decides what the app sees. Only a failed init() stops an app; every // debouncing are its business -- and main.lua decides who sees it. Only a
// other callback logs and carries on. // failed start() stops an app; every other callback logs and carries on.
bool callInit(const std::string& arg); bool callStart(const std::string& argsJson);
void callDraw(int32_t deltaMs); void callDraw(int32_t deltaMs);
// An Up phase also fires the on_touch tap alias, in that order. // An Up phase also fires the on_touch tap alias, in that order.
void callTouch(TouchPhase phase, int32_t x, int32_t y); void callTouch(TouchPhase phase, int32_t x, int32_t y);
@@ -125,18 +116,17 @@ private:
bool repeating; bool repeating;
}; };
struct Route {
std::string path;
std::string arg;
};
struct Pending { struct Pending {
enum Kind { None, Launch, Replace, Back } kind = None; bool pending = false;
Route route; std::string path;
std::string argsJson;
}; };
bool loadScript(const std::string& path); bool loadScript(const std::string& path);
void installLoader(const std::string& appDir); // Runs the app's entry file and keeps the table it returns; the app is
// mounted by that table, not by the runtime.
bool loadMain(const std::string& path);
void installLoader();
static int searchModule(lua_State* state); static int searchModule(lua_State* state);
static int searchEmbedded(lua_State* state); static int searchEmbedded(lua_State* state);
static int loadFile(lua_State* state); static int loadFile(lua_State* state);
@@ -155,9 +145,10 @@ private:
Runtime& runtime_; Runtime& runtime_;
}; };
// Pushes the named global, or returns false when the app does not define it. // Pushes main.<name>, or returns false when main.lua defines no such handler.
bool beginCall(const char* name); bool beginCall(const char* name);
bool finishCall(const char* name, int argc); bool finishCall(const char* name, int argc);
bool finishCallValue(const char* name);
void cancelAllTimers(); void cancelAllTimers();
Providers providers_; Providers providers_;
@@ -167,10 +158,10 @@ private:
TimerId nextTimerId_ = 1; TimerId nextTimerId_ = 1;
int batchDepth_ = 0; int batchDepth_ = 0;
Paths paths_; // Registry reference to the table the entry file returned, or 0 before one
// loads.
int mainRef_ = 0;
std::string appPath_; std::string appPath_;
std::string appTitle_;
std::vector<Route> history_;
Pending pending_; Pending pending_;
}; };
+75 -15
View File
@@ -1,8 +1,14 @@
// @lua-module ble BleLib // @lua-module ble BleLib
// @lua-preamble ---@class BleDevice // @lua-preamble ---@class BleObservation
// @lua-preamble ---@field name string
// @lua-preamble ---@field address string // @lua-preamble ---@field address string
// @lua-preamble ---@field name string
// @lua-preamble ---@field rssi integer // @lua-preamble ---@field rssi integer
// @lua-preamble ---@field payload string Raw advertisement bytes.
// @lua-preamble ---@field lastSeenMs integer sys.getMillis() at last sighting.
// @lua-preamble
// @lua-preamble ---@class BleFilter
// @lua-preamble ---@field services? string[] Service-data UUIDs to keep.
// @lua-preamble ---@field manufacturers? integer[] Manufacturer ids to keep.
#include "../helpers.h" #include "../helpers.h"
@@ -30,21 +36,67 @@ int isInitialized(lua_State* state) {
return 1; return 1;
} }
int scan(lua_State* state) { void readStringArray(lua_State* state, int index, const char* key,
const int32_t durationMs = optionalInt(state, 1, 3000); std::vector<std::string>& out) {
luaL_argcheck(state, durationMs > 0, 1, "must be positive"); lua_getfield(state, index, key);
if (lua_istable(state, -1)) {
const int count = static_cast<int>(lua_rawlen(state, -1));
for (int at = 1; at <= count; at++) {
lua_rawgeti(state, -1, at);
if (lua_type(state, -1) == LUA_TSTRING)
out.push_back(lua_tostring(state, -1));
lua_pop(state, 1);
}
}
lua_pop(state, 1);
}
std::vector<BleDevice> devices; void readIntArray(lua_State* state, int index, const char* key,
const Status status = Runtime::from(state)->ble().scan(durationMs, devices); std::vector<int32_t>& out) {
if (!status.ok) lua_getfield(state, index, key);
return pushError(state, status.error); if (lua_istable(state, -1)) {
const int count = static_cast<int>(lua_rawlen(state, -1));
for (int at = 1; at <= count; at++) {
lua_rawgeti(state, -1, at);
if (lua_type(state, -1) == LUA_TNUMBER)
out.push_back(static_cast<int32_t>(lua_tointeger(state, -1)));
lua_pop(state, 1);
}
}
lua_pop(state, 1);
}
int observe(lua_State* state) {
BleFilter filter;
if (lua_istable(state, 1)) {
readStringArray(state, 1, "services", filter.services);
readIntArray(state, 1, "manufacturers", filter.manufacturers);
}
return pushStatus(state, Runtime::from(state)->ble().observe(filter));
}
int unobserve(lua_State* state) {
Runtime::from(state)->ble().unobserve();
return 0;
}
int isObserving(lua_State* state) {
lua_pushboolean(state, Runtime::from(state)->ble().isObserving());
return 1;
}
int observed(lua_State* state) {
std::vector<BleObservation> devices;
Runtime::from(state)->ble().observed(devices);
lua_createtable(state, static_cast<int>(devices.size()), 0); lua_createtable(state, static_cast<int>(devices.size()), 0);
for (size_t at = 0; at < devices.size(); at++) { for (size_t at = 0; at < devices.size(); at++) {
lua_createtable(state, 0, 3); lua_createtable(state, 0, 5);
setField(state, "name", devices[at].name);
setField(state, "address", devices[at].address); setField(state, "address", devices[at].address);
setField(state, "name", devices[at].name);
setField(state, "rssi", devices[at].rssi); setField(state, "rssi", devices[at].rssi);
setField(state, "payload", devices[at].payload);
setField(state, "lastSeenMs", devices[at].lastSeenMs);
lua_rawseti(state, -2, static_cast<lua_Integer>(at + 1)); lua_rawseti(state, -2, static_cast<lua_Integer>(at + 1));
} }
return 1; return 1;
@@ -108,11 +160,19 @@ const luaL_Reg FUNCTIONS[] = {
// --- Whether the BLE stack is running. // --- Whether the BLE stack is running.
// @return boolean // @return boolean
{"isInitialized", isInitialized}, {"isInitialized", isInitialized},
// --- Scans for advertising devices. // --- Starts passively observing advertisements, coalesced per device.
// @param durationMs integer|nil Defaults to 3000. // @param filter BleFilter|nil Keep only matching adverts; nil keeps all.
// @return BleDevice[]|nil devices // @return true|nil ok
// @return string|nil error // @return string|nil error
{"scan", scan}, {"observe", observe},
// --- Stops observing and clears the snapshot.
{"unobserve", unobserve},
// --- Whether advertisement observation is running.
// @return boolean
{"isObserving", isObserving},
// --- The current snapshot of observed devices.
// @return BleObservation[] devices
{"observed", observed},
// --- Connects to a peripheral. // --- Connects to a peripheral.
// @param address string // @param address string
// @return true|nil ok // @return true|nil ok
-78
View File
@@ -1,78 +0,0 @@
// @lua-module settings SettingsLib
#include <lua/runtime.h>
extern "C" {
#include "lauxlib.h"
#include "lua.h"
}
namespace esp32lua {
namespace bindings {
namespace {
int pushStatus(lua_State* state, const Status& status) {
if (status.ok) {
lua_pushboolean(state, true);
return 1;
}
lua_pushnil(state);
lua_pushlstring(state, status.error.data(), status.error.size());
return 2;
}
int getRotation(lua_State* state) {
lua_pushinteger(state, Runtime::from(state)->settings().rotation());
return 1;
}
int setRotation(lua_State* state) {
const lua_Integer degrees = luaL_checkinteger(state, 1);
luaL_argcheck(state, degrees >= 0 && degrees <= 270 && degrees % 90 == 0, 1,
"expected 0, 90, 180, or 270");
return pushStatus(state,
Runtime::from(state)->settings().setRotation(degrees));
}
int getTimezone(lua_State* state) {
const std::string timezone = Runtime::from(state)->settings().timezone();
lua_pushlstring(state, timezone.data(), timezone.size());
return 1;
}
int setTimezone(lua_State* state) {
size_t length = 0;
const char* value = luaL_checklstring(state, 1, &length);
return pushStatus(
state, Runtime::from(state)->settings().setTimezone({value, length}));
}
const luaL_Reg FUNCTIONS[] = {
// --- Returns the saved rotation in degrees clockwise.
// @return integer
{"getRotation", getRotation},
// --- Applies and persists the screen rotation.
// @param degrees integer 0, 90, 180, or 270 clockwise.
// @return true|nil ok
// @return string|nil error
{"setRotation", setRotation},
// --- Returns the active POSIX timezone rule.
// @return string
{"getTimezone", getTimezone},
// --- Applies and persists a POSIX timezone rule.
// @param timezone string
// @return true|nil ok
// @return string|nil error
{"setTimezone", setTimezone},
{nullptr, nullptr},
};
} // namespace
void registerSettings(lua_State* state) {
luaL_newlib(state, FUNCTIONS);
lua_setglobal(state, "settings");
}
} // namespace bindings
} // namespace esp32lua
+35 -52
View File
@@ -1,5 +1,5 @@
// @lua-module sys SysLib // @lua-module sys SysLib
// @lua-preamble ---@alias Feature "touch"|"buttons" // @lua-preamble ---@alias Feature "screen"|"touch"|"buttons"
#include "../helpers.h" #include "../helpers.h"
@@ -20,34 +20,17 @@ int getMillis(lua_State* state) {
lua_pushinteger(state, Runtime::from(state)->sys().millis()); lua_pushinteger(state, Runtime::from(state)->sys().millis());
return 1; return 1;
} }
int getAppID(lua_State* state) { // Encoding happens here, in the state that still holds the table, so an app
pushString(state, Runtime::from(state)->appId()); // passing something JSON cannot carry raises at its own call rather than
return 1; // stranding the launch.
} int startApp(lua_State* state) {
int getAppTitle(lua_State* state) { luaL_checkstring(state, 1);
pushString(state, Runtime::from(state)->appTitle()); if (!lua_isnoneornil(state, 2))
return 1; luaL_checktype(state, 2, LUA_TTABLE);
} const std::string json =
int getAppDataPath(lua_State* state) { lua_isnoneornil(state, 2) ? std::string() : encodeJson(state, 2);
pushString(state, Runtime::from(state)->appDataPath());
return 1;
}
int setAppTitle(lua_State* state) {
Runtime::from(state)->setAppTitle(luaL_checkstring(state, 1));
return 0;
}
int navigate(lua_State* state, bool replace) {
const std::string path = checkString(state, 1); const std::string path = checkString(state, 1);
const std::string arg = Runtime::from(state)->requestStart(path, json);
lua_isnoneornil(state, 2) ? std::string() : checkString(state, 2);
Runtime::from(state)->requestLaunch(path, arg, replace);
return 0;
}
int launch(lua_State* state) { return navigate(state, false); }
int replace(lua_State* state) { return navigate(state, true); }
int back(lua_State* state) {
Runtime::from(state)->requestBack();
return 0; return 0;
} }
@@ -62,6 +45,14 @@ int isClockSynced(lua_State* state) {
lua_pushboolean(state, Runtime::from(state)->sys().isClockSynced()); lua_pushboolean(state, Runtime::from(state)->sys().isClockSynced());
return 1; return 1;
} }
int getTimezone(lua_State* state) {
pushString(state, Runtime::from(state)->sys().timezone());
return 1;
}
int setTimezone(lua_State* state) {
const std::string timezone = checkString(state, 1);
return pushStatus(state, Runtime::from(state)->sys().setTimezone(timezone));
}
const luaL_Reg FUNCTIONS[] = { const luaL_Reg FUNCTIONS[] = {
// --- Returns the implemented API contract version. // --- Returns the implemented API contract version.
@@ -74,30 +65,14 @@ const luaL_Reg FUNCTIONS[] = {
// --- Returns monotonic milliseconds since boot. // --- Returns monotonic milliseconds since boot.
// @return integer // @return integer
{"getMillis", getMillis}, {"getMillis", getMillis},
// --- Returns the immutable first path component of the running app. // --- Tears the runtime down and starts over from a Lua file, which is the
// @return string // only navigation there is: history, titles and where apps live are
{"getAppID", getAppID}, // whatever that file makes of the arguments.
// --- Returns the running app title, initially the app ID. // @param path string Absolute path to the Lua file to load; traversal is
// @return string // rejected.
{"getAppTitle", getAppTitle}, // @param args table|nil Plain data, carried across the teardown as JSON and
// --- Returns the current app's guaranteed-existing persistent data // handed to start(args). Raises on anything JSON cannot represent.
// directory. {"startApp", startApp},
// @return string Absolute path under /.lua/data, preserved across app
// updates.
{"getAppDataPath", getAppDataPath},
// --- Changes the running app's display title.
// @param title string
{"setAppTitle", setAppTitle},
// --- Launches /.lua/apps/<path>/main.lua and pushes the current route.
// @param path string App-relative directory path; traversal is rejected.
// @param arg string|nil Passed to init(arg).
{"launch", launch},
// --- Launches an app path without retaining the current route.
// @param path string App-relative directory path; traversal is rejected.
// @param arg string|nil Passed to init(arg).
{"replace", replace},
// --- Returns to the previous app, or the launcher when history is empty.
{"back", back},
// --- Returns heap statistics. // --- Returns heap statistics.
// @return integer freeBytes // @return integer freeBytes
// @return integer totalBytes // @return integer totalBytes
@@ -106,6 +81,14 @@ const luaL_Reg FUNCTIONS[] = {
// --- Whether network time synchronization has completed. // --- Whether network time synchronization has completed.
// @return boolean // @return boolean
{"isClockSynced", isClockSynced}, {"isClockSynced", isClockSynced},
// --- Returns the active POSIX timezone rule.
// @return string
{"getTimezone", getTimezone},
// --- Applies and persists a POSIX timezone rule.
// @param timezone string
// @return true|nil ok
// @return string|nil error
{"setTimezone", setTimezone},
{nullptr, nullptr}, {nullptr, nullptr},
}; };
+15 -10
View File
@@ -32,18 +32,18 @@ int wasReleased(lua_State* state) {
return 1; return 1;
} }
// @lua-augment input InputLib // @lua-module buttons ButtonsLib
// @lua-preamble ---@alias Button "up"|"down"|"left"|"right"|"confirm"|"back" // @lua-preamble ---@alias Button "up"|"down"|"left"|"right"|"confirm"|"back"
// @lua-preamble // @lua-preamble
// @lua-preamble -- Roles, not physical buttons: a device maps whatever hardware // @lua-preamble -- Roles, not physical buttons: a device maps whatever hardware
// it has onto them, and // it has onto them, and
// @lua-preamble -- up/down/left/right are the directions node.moveFocus already // @lua-preamble -- up/down/left/right are the directions tree.moveFocus already
// takes. // takes.
const luaL_Reg INPUT_FUNCTIONS[] = { const luaL_Reg FUNCTIONS[] = {
// ---Returns the roles this device reports, so an app can label only the // ---Returns the roles this device reports, so an app can label only the
// actions it has. // actions it has.
// @return Button[] // @return Button[]
{"getButtons", getButtons}, {"getAll", getButtons},
// ---Whether any button is held. // ---Whether any button is held.
// @return boolean // @return boolean
{"isAnyPressed", isAnyPressed}, {"isAnyPressed", isAnyPressed},
@@ -65,19 +65,24 @@ const luaL_Reg INPUT_FUNCTIONS[] = {
} // namespace } // namespace
void registerButtons(lua_State* state) { void registerButtons(lua_State* state) {
augmentGlobal(state, "input", INPUT_FUNCTIONS); luaL_newlib(state, FUNCTIONS);
lua_setglobal(state, "buttons");
} }
} // namespace bindings } // namespace bindings
} // namespace esp32lua } // namespace esp32lua
// @lua-global // @lua-app ButtonHandlers
// @lua-preamble -- What an app implements to see buttons, composed into its own
// class:
// @lua-preamble --
// @lua-preamble -- ---@class MenuApp : App, ButtonHandlers
// ---Fired when a button goes down. // ---Fired when a button goes down.
// @param button Button // @param button Button
// @lua-fn on_button_down // @lua-fn onButtonDown?
// ---Fired when a button comes up. // ---Fired when a button comes up.
// @param button Button // @param button Button
// @lua-fn on_button_up // @lua-fn onButtonUp?
// ---Tap alias, fired on release like a click, after on_button_up. // ---Tap alias, fired on release like a click, after onButtonUp.
// @param button Button // @param button Button
// @lua-fn on_button // @lua-fn onButton?
@@ -1,15 +1,17 @@
// @lua-module gui GuiLib // @lua-module screen ScreenLib
// @lua-preamble ---@alias GuiColor integer // @lua-preamble -- The panel itself; the widget tree it paints is `tree`, and
// @lua-preamble ---@alias GuiFont integer // @lua-preamble -- sys.hasFeature("screen") covers both.
// @lua-preamble ---@alias GuiTextStyle integer // @lua-preamble ---@alias ScreenColor integer
// @lua-const FONT_SMALL GuiFont 0 Small auxiliary text. // @lua-preamble ---@alias ScreenFont integer
// @lua-const FONT_UI GuiFont 0 Normal controls and labels. // @lua-preamble ---@alias ScreenTextStyle integer
// @lua-const FONT_BODY GuiFont 0 Normal reading text. // @lua-const FONT_SMALL ScreenFont 0 Small auxiliary text.
// @lua-const FONT_LARGE GuiFont 0 Headings and prominent values. // @lua-const FONT_UI ScreenFont 0 Normal controls and labels.
// @lua-const STYLE_NORMAL GuiTextStyle 0 // @lua-const FONT_BODY ScreenFont 0 Normal reading text.
// @lua-const STYLE_BOLD GuiTextStyle 0 // @lua-const FONT_LARGE ScreenFont 0 Headings and prominent values.
// @lua-const STYLE_NORMAL ScreenTextStyle 0
// @lua-const STYLE_BOLD ScreenTextStyle 0
#include "../helpers.h" #include "../../helpers.h"
namespace esp32lua { namespace esp32lua {
namespace bindings { namespace bindings {
@@ -36,8 +38,17 @@ int setRotation(lua_State* state) {
const int32_t degrees = checkInt(state, 1); const int32_t degrees = checkInt(state, 1);
luaL_argcheck(state, degrees >= 0 && degrees <= 270 && degrees % 90 == 0, 1, luaL_argcheck(state, degrees >= 0 && degrees <= 270 && degrees % 90 == 0, 1,
"expected 0, 90, 180, or 270"); "expected 0, 90, 180, or 270");
provider(state).setRotation(degrees); return pushStatus(state, provider(state).setRotation(degrees));
return 0; }
int getTheme(lua_State* state) {
pushString(state, provider(state).theme());
return 1;
}
int setTheme(lua_State* state) {
const std::string theme = checkString(state, 1);
return pushStatus(state, provider(state).setTheme(theme));
} }
int color(lua_State* state) { int color(lua_State* state) {
@@ -125,12 +136,6 @@ int roundRect(lua_State* state) {
return 0; return 0;
} }
int setFullscreen(lua_State* state) {
luaL_checkany(state, 1);
provider(state).setFullscreen(lua_toboolean(state, 1) != 0);
return 0;
}
void readIntegers(lua_State* state, int index, std::vector<int32_t>& out) { void readIntegers(lua_State* state, int index, std::vector<int32_t>& out) {
const lua_Integer count = luaL_len(state, index); const lua_Integer count = luaL_len(state, index);
for (lua_Integer at = 1; at <= count; at++) { for (lua_Integer at = 1; at <= count; at++) {
@@ -221,62 +226,76 @@ const luaL_Reg FUNCTIONS[] = {
// --- Returns the live frame height. // --- Returns the live frame height.
// @return integer // @return integer
{"getHeight", getHeight}, {"getHeight", getHeight},
// --- Rotates the live frame without changing the saved preference. // --- Rotates the panel and persists the choice, so there is one rotation
// --- rather than a live one and a saved one to reconcile.
// @param degrees integer 0, 90, 180, or 270 clockwise. // @param degrees integer 0, 90, 180, or 270 clockwise.
// @return true|nil ok
// @return string|nil error
{"setRotation", setRotation}, {"setRotation", setRotation},
// --- Returns the rotation of the live frame. // --- Returns the rotation in degrees clockwise.
// @return integer Degrees clockwise for the live frame. // @return integer
{"getRotation", getRotation}, {"getRotation", getRotation},
// --- Returns the saved palette name. Apps read ui.getTheme() instead; this
// --- is the stored value, which only ui.setTheme() knows how to apply.
// @return string
{"getTheme", getTheme},
// --- Persists a palette name without applying it. Call ui.setTheme(),
// which
// --- writes through here and then rebuilds the palette and repaints.
// @param theme string
// @return true|nil ok
// @return string|nil error
{"setTheme", setTheme},
// --- Returns an opaque native color. E-ink implementations quantize RGB to // --- Returns an opaque native color. E-ink implementations quantize RGB to
// available grayscale. // available grayscale.
// @param r integer 0 through 255. // @param r integer 0 through 255.
// @param g integer 0 through 255. // @param g integer 0 through 255.
// @param b integer 0 through 255. // @param b integer 0 through 255.
// @return GuiColor // @return ScreenColor
{"color", color}, {"color", color},
// --- Clears the frame. // --- Clears the frame.
// @param color GuiColor|nil Defaults to white. // @param color ScreenColor|nil Defaults to white.
{"clear", clear}, {"clear", clear},
// --- Fills a rectangle. // --- Fills a rectangle.
// @param x integer // @param x integer
// @param y integer // @param y integer
// @param w integer // @param w integer
// @param h integer // @param h integer
// @param color GuiColor // @param color ScreenColor
{"fillRect", fillRect}, {"fillRect", fillRect},
// --- Outlines a rectangle. // --- Outlines a rectangle.
// @param x integer // @param x integer
// @param y integer // @param y integer
// @param w integer // @param w integer
// @param h integer // @param h integer
// @param color GuiColor // @param color ScreenColor
{"drawRect", drawRect}, {"drawRect", drawRect},
// --- Draws a line. // --- Draws a line.
// @param x1 integer // @param x1 integer
// @param y1 integer // @param y1 integer
// @param x2 integer // @param x2 integer
// @param y2 integer // @param y2 integer
// @param color GuiColor // @param color ScreenColor
// @param width integer|nil Defaults to one pixel. // @param width integer|nil Defaults to one pixel.
{"drawLine", drawLine}, {"drawLine", drawLine},
// --- Draws a single pixel. // --- Draws a single pixel.
// @param x integer // @param x integer
// @param y integer // @param y integer
// @param color GuiColor // @param color ScreenColor
{"drawPixel", drawPixel}, {"drawPixel", drawPixel},
// --- Outlines a circle. // --- Outlines a circle.
// @param x integer Center. // @param x integer Center.
// @param y integer Center. // @param y integer Center.
// @param radius integer // @param radius integer
// @param color GuiColor // @param color ScreenColor
// @param width integer|nil Defaults to one pixel. // @param width integer|nil Defaults to one pixel.
{"drawCircle", drawCircle}, {"drawCircle", drawCircle},
// --- Fills a circle. // --- Fills a circle.
// @param x integer Center. // @param x integer Center.
// @param y integer Center. // @param y integer Center.
// @param radius integer // @param radius integer
// @param color GuiColor // @param color ScreenColor
// @param background GuiColor|nil Surface behind an anti-aliased edge. // @param background ScreenColor|nil Surface behind an anti-aliased edge.
{"fillCircle", fillCircle}, {"fillCircle", fillCircle},
// ---Draws an anti-aliased rounded fill, optional gradient, and optional // ---Draws an anti-aliased rounded fill, optional gradient, and optional
// border in one pass. // border in one pass.
@@ -285,19 +304,16 @@ const luaL_Reg FUNCTIONS[] = {
// @param w integer // @param w integer
// @param h integer // @param h integer
// @param radius integer // @param radius integer
// @param background GuiColor Surface behind the anti-aliased edge. // @param background ScreenColor Surface behind the anti-aliased edge.
// @param top GuiColor|nil Fill, or gradient top; omitted for no fill. // @param top ScreenColor|nil Fill, or gradient top; omitted for no fill.
// @param bottom GuiColor|nil Gradient bottom; defaults to top. Panels // @param bottom ScreenColor|nil Gradient bottom; defaults to top. Panels
// without a gradient use top. // without a gradient use top.
// @param border GuiColor|nil Omitted for no border. // @param border ScreenColor|nil Omitted for no border.
{"roundRect", roundRect}, {"roundRect", roundRect},
// ---Temporarily gives the app the full panel, including firmware chrome.
// @param on boolean
{"setFullscreen", setFullscreen},
// --- Fills a polygon. // --- Fills a polygon.
// @param xs integer[] // @param xs integer[]
// @param ys integer[] // @param ys integer[]
// @param color GuiColor // @param color ScreenColor
{"fillPolygon", fillPolygon}, {"fillPolygon", fillPolygon},
// --- Draws a bitmap. // --- Draws a bitmap.
// @param path string Absolute BMP path. // @param path string Absolute BMP path.
@@ -309,31 +325,31 @@ const luaL_Reg FUNCTIONS[] = {
// @return string|nil error // @return string|nil error
{"drawBmp", drawBmp}, {"drawBmp", drawBmp},
// --- Measures a text run. // --- Measures a text run.
// @param font GuiFont Use a named gui.FONT_* role. // @param font ScreenFont Use a named screen.FONT_* role.
// @param text string // @param text string
// @param style GuiTextStyle|nil Defaults to gui.STYLE_NORMAL. // @param style ScreenTextStyle|nil Defaults to screen.STYLE_NORMAL.
// @return integer // @return integer
{"getTextWidth", getTextWidth}, {"getTextWidth", getTextWidth},
// --- Returns the line height of a font role. // --- Returns the line height of a font role.
// @param font GuiFont Use a named gui.FONT_* role. // @param font ScreenFont Use a named screen.FONT_* role.
// @param style GuiTextStyle|nil Defaults to gui.STYLE_NORMAL. // @param style ScreenTextStyle|nil Defaults to screen.STYLE_NORMAL.
// @return integer // @return integer
{"getFontHeight", getFontHeight}, {"getFontHeight", getFontHeight},
// --- Draws a text run with its top-left corner at x, y. // --- Draws a text run with its top-left corner at x, y.
// @param font GuiFont Use a named gui.FONT_* role. // @param font ScreenFont Use a named screen.FONT_* role.
// @param x integer Left edge. // @param x integer Left edge.
// @param y integer Top edge. // @param y integer Top edge.
// @param text string // @param text string
// @param color GuiColor|nil Defaults to black. // @param color ScreenColor|nil Defaults to black.
// @param style GuiTextStyle|nil Defaults to gui.STYLE_NORMAL. // @param style ScreenTextStyle|nil Defaults to screen.STYLE_NORMAL.
// @param background GuiColor|nil Omitted for transparent text. // @param background ScreenColor|nil Omitted for transparent text.
{"drawText", drawText}, {"drawText", drawText},
{nullptr, nullptr}, {nullptr, nullptr},
}; };
} // namespace } // namespace
void registerGui(lua_State* state) { void registerScreen(lua_State* state) {
luaL_newlib(state, FUNCTIONS); luaL_newlib(state, FUNCTIONS);
const FontIds fonts = Runtime::from(state)->gui().fonts(); const FontIds fonts = Runtime::from(state)->gui().fonts();
setField(state, "FONT_SMALL", fonts.small); setField(state, "FONT_SMALL", fonts.small);
@@ -342,7 +358,7 @@ void registerGui(lua_State* state) {
setField(state, "FONT_LARGE", fonts.large); setField(state, "FONT_LARGE", fonts.large);
setField(state, "STYLE_NORMAL", fonts.styleNormal); setField(state, "STYLE_NORMAL", fonts.styleNormal);
setField(state, "STYLE_BOLD", fonts.styleBold); setField(state, "STYLE_BOLD", fonts.styleBold);
lua_setglobal(state, "gui"); lua_setglobal(state, "screen");
} }
} // namespace bindings } // namespace bindings
@@ -1,4 +1,4 @@
// @lua-module node NodeLib // @lua-module tree TreeLib
// @lua-preamble ---@alias NodeId integer // @lua-preamble ---@alias NodeId integer
// @lua-preamble ---@alias NodeType "box"|"text"|"button"|"custom" // @lua-preamble ---@alias NodeType "box"|"text"|"button"|"custom"
// @lua-preamble ---@alias NodeDirection "up"|"down"|"left"|"right" // @lua-preamble ---@alias NodeDirection "up"|"down"|"left"|"right"
@@ -13,30 +13,29 @@
// @lua-preamble ---@field justify? "start"|"center"|"end"|"between" // @lua-preamble ---@field justify? "start"|"center"|"end"|"between"
// @lua-preamble ---@field row? boolean // @lua-preamble ---@field row? boolean
// @lua-preamble ---@field at? table Absolute-position fields. // @lua-preamble ---@field at? table Absolute-position fields.
// @lua-preamble ---@field capture? boolean
// @lua-preamble ---@field interactive? boolean // @lua-preamble ---@field interactive? boolean
// @lua-preamble ---@field label? string // @lua-preamble ---@field label? string
// @lua-preamble ---@field font? GuiFont // @lua-preamble ---@field font? ScreenFont
// @lua-preamble // @lua-preamble
// @lua-preamble ---@class NodeStyle // @lua-preamble ---@class NodeStyle
// @lua-preamble ---@field color? GuiColor // @lua-preamble ---@field color? ScreenColor
// @lua-preamble ---@field background? GuiColor Background offered to // @lua-preamble ---@field background? ScreenColor Background offered to
// descendants. // descendants.
// @lua-preamble ---@field fill? GuiColor Surface painted by a box. // @lua-preamble ---@field fill? ScreenColor Surface painted by a box.
// @lua-preamble ---@field border? GuiColor // @lua-preamble ---@field border? ScreenColor
// @lua-preamble ---@field face? GuiColor Default button surface. // @lua-preamble ---@field face? ScreenColor Default button surface.
// @lua-preamble ---@field pressedFace? GuiColor Pressed button surface. // @lua-preamble ---@field pressedFace? ScreenColor Pressed button surface.
// @lua-preamble ---@field pressedColor? GuiColor Pressed button text. // @lua-preamble ---@field pressedColor? ScreenColor Pressed button text.
// @lua-preamble ---@field focusColor? GuiColor Distinct outline for directional // @lua-preamble ---@field focusColor? ScreenColor Distinct outline for
// focus. // directional focus.
// @lua-preamble ---@field radius? integer // @lua-preamble ---@field radius? integer
// @lua-preamble ---@field font? GuiFont // @lua-preamble ---@field font? ScreenFont
// @lua-preamble ---@field textStyle? GuiTextStyle // @lua-preamble ---@field textStyle? ScreenTextStyle
#include <cstdlib> #include <cstdlib>
#include "../../node/painter.h" #include "../../../node/painter.h"
#include "../helpers.h" #include "../../helpers.h"
namespace esp32lua { namespace esp32lua {
namespace bindings { namespace bindings {
@@ -173,10 +172,12 @@ int create(lua_State* state) {
uint8_t flags = 0; uint8_t flags = 0;
if (readFlag(state, 2, "row")) if (readFlag(state, 2, "row"))
flags |= ui::ROW; flags |= ui::ROW;
if (readFlag(state, 2, "capture"))
flags |= ui::CAPTURE;
if (readFlag(state, 2, "interactive")) if (readFlag(state, 2, "interactive"))
flags |= ui::INTERACTIVE; flags |= ui::INTERACTIVE;
if (readFlag(state, 2, "scrollX"))
flags |= ui::SCROLL_X;
if (readFlag(state, 2, "scrollY"))
flags |= ui::SCROLL_Y;
lua_getfield(state, 2, "font"); lua_getfield(state, 2, "font");
const int32_t font = lua_isnoneornil(state, -1) const int32_t font = lua_isnoneornil(state, -1)
@@ -270,6 +271,27 @@ int getRect(lua_State* state) {
return 4; return 4;
} }
int setScroll(lua_State* state) {
tree(state).setScroll(checkNode(state, 1), checkInt(state, 2),
checkInt(state, 3));
return 0;
}
int getScroll(lua_State* state) {
const ui::Scroll* scroll = tree(state).scrollOf(checkNode(state, 1));
lua_pushinteger(state, scroll ? scroll->x : 0);
lua_pushinteger(state, scroll ? scroll->y : 0);
return 2;
}
int getScrollRange(lua_State* state) {
int maxX = 0, maxY = 0;
tree(state).scrollRange(checkNode(state, 1), maxX, maxY);
lua_pushinteger(state, maxX);
lua_pushinteger(state, maxY);
return 2;
}
int setLabel(lua_State* state) { int setLabel(lua_State* state) {
const uint16_t id = checkNode(state, 1); const uint16_t id = checkNode(state, 1);
const char* text = luaL_checkstring(state, 2); const char* text = luaL_checkstring(state, 2);
@@ -467,7 +489,8 @@ int moveFocus(lua_State* state) {
return 1; return 1;
} }
void callPainter(void* context, uint16_t id, int x, int y, int w, int h) { void callPainter(void* context, uint16_t id, int x, int y, int w, int h,
int clipX, int clipY, int clipW, int clipH) {
lua_State* state = static_cast<lua_State*>(context); lua_State* state = static_cast<lua_State*>(context);
lua_getfield(state, LUA_REGISTRYINDEX, PAINTER_KEY); lua_getfield(state, LUA_REGISTRYINDEX, PAINTER_KEY);
if (!lua_isfunction(state, -1)) { if (!lua_isfunction(state, -1)) {
@@ -479,7 +502,11 @@ void callPainter(void* context, uint16_t id, int x, int y, int w, int h) {
lua_pushinteger(state, y); lua_pushinteger(state, y);
lua_pushinteger(state, w); lua_pushinteger(state, w);
lua_pushinteger(state, h); lua_pushinteger(state, h);
if (lua_pcall(state, 5, 0, 0) != LUA_OK) { lua_pushinteger(state, clipX);
lua_pushinteger(state, clipY);
lua_pushinteger(state, clipW);
lua_pushinteger(state, clipH);
if (lua_pcall(state, 9, 0, 0) != LUA_OK) {
Runtime::from(state)->log().write( Runtime::from(state)->log().write(
LogLevel::Error, LogLevel::Error,
lua_tostring(state, -1) ? lua_tostring(state, -1) : "painter"); lua_tostring(state, -1) ? lua_tostring(state, -1) : "painter");
@@ -554,6 +581,21 @@ const luaL_Reg FUNCTIONS[] = {
// @return integer w // @return integer w
// @return integer h // @return integer h
{"getRect", getRect}, {"getRect", getRect},
// --- Pans a scrollable node's children, clamped to the content. The node
// is marked dirty, so the next draw repaints it.
// @param id NodeId
// @param x integer
// @param y integer
{"setScroll", setScroll},
// --- Returns a scrollable node's current offset, or zeroes.
// @param id NodeId
// @return integer x, integer y
{"getScroll", getScroll},
// --- Returns how far each axis can pan before the content's far edge
// reaches the box. Zero on an axis whose content fits.
// @param id NodeId
// @return integer maxX, integer maxY
{"getScrollRange", getScrollRange},
// --- Replaces a node's text and marks it for repaint. // --- Replaces a node's text and marks it for repaint.
// @param id NodeId // @param id NodeId
// @param text string // @param text string
@@ -597,9 +639,12 @@ const luaL_Reg FUNCTIONS[] = {
// @param direction NodeDirection // @param direction NodeDirection
// @return NodeId|nil focused Current focus when no candidate exists. // @return NodeId|nil focused Current focus when no candidate exists.
{"moveFocus", moveFocus}, {"moveFocus", moveFocus},
// --- Registers the painter every custom node calls. // --- Registers the painter every custom node calls. The clip arguments are
// the region of the node being painted now -- one band of a composited
// repaint -- so a painter can cull to it instead of redrawing itself once
// per band.
// @param painter fun(id: NodeId, x: integer, y: integer, w: integer, h: // @param painter fun(id: NodeId, x: integer, y: integer, w: integer, h:
// integer) // integer, clipX: integer, clipY: integer, clipW: integer, clipH: integer)
{"setPainter", setPainter}, {"setPainter", setPainter},
// --- Paints dirty nodes; the firmware owns publication to the physical // --- Paints dirty nodes; the firmware owns publication to the physical
// display. // display.
@@ -616,9 +661,9 @@ const luaL_Reg FUNCTIONS[] = {
} // namespace } // namespace
void registerNode(lua_State* state) { void registerTree(lua_State* state) {
luaL_newlib(state, FUNCTIONS); luaL_newlib(state, FUNCTIONS);
lua_setglobal(state, "node"); lua_setglobal(state, "tree");
} }
} // namespace bindings } // namespace bindings
+27 -28
View File
@@ -40,8 +40,21 @@ int isTouched(lua_State* state) {
return 1; return 1;
} }
// @lua-augment settings SettingsLib // @lua-module touch TouchLib
const luaL_Reg SETTINGS_FUNCTIONS[] = { const luaL_Reg FUNCTIONS[] = {
// --- Returns the calibrated touch point, or nothing when the panel is not
// touched.
// @return integer|nil x
// @return integer|nil y
{"getPoint", getTouch},
// --- Returns the uncalibrated touch reading, or nothing when the panel is
// not touched.
// @return integer|nil x
// @return integer|nil y
{"getRawPoint", getRawTouch},
// --- Whether the panel is currently touched.
// @return boolean
{"isTouched", isTouched},
// --- Persists the panel's touch calibration. // --- Persists the panel's touch calibration.
// @param x0 integer Raw reading at the left edge. // @param x0 integer Raw reading at the left edge.
// @param y0 integer Raw reading at the top edge. // @param y0 integer Raw reading at the top edge.
@@ -53,49 +66,35 @@ const luaL_Reg SETTINGS_FUNCTIONS[] = {
{nullptr, nullptr}, {nullptr, nullptr},
}; };
// @lua-augment input InputLib
const luaL_Reg INPUT_FUNCTIONS[] = {
// --- Returns the calibrated touch point, or nothing when the panel is not
// touched.
// @return integer|nil x
// @return integer|nil y
{"getTouch", getTouch},
// --- Returns the uncalibrated touch reading, or nothing when the panel is
// not touched.
// @return integer|nil x
// @return integer|nil y
{"getRawTouch", getRawTouch},
// --- Whether the panel is currently touched.
// @return boolean
{"isTouched", isTouched},
{nullptr, nullptr},
};
} // namespace } // namespace
void registerTouch(lua_State* state) { void registerTouch(lua_State* state) {
augmentGlobal(state, "settings", SETTINGS_FUNCTIONS); luaL_newlib(state, FUNCTIONS);
augmentGlobal(state, "input", INPUT_FUNCTIONS); lua_setglobal(state, "touch");
} }
} // namespace bindings } // namespace bindings
} // namespace esp32lua } // namespace esp32lua
// @lua-global // @lua-app TouchHandlers
// @lua-preamble -- What an app implements to see raw touch, composed into its
// own class:
// @lua-preamble --
// @lua-preamble -- ---@class PaintApp : App, TouchHandlers
// ---Fired when the finger lands. // ---Fired when the finger lands.
// @param x integer // @param x integer
// @param y integer // @param y integer
// @lua-fn on_touch_down // @lua-fn onTouchDown?
// ---Fired when the finger moves while down, after the firmware's jitter // ---Fired when the finger moves while down, after the firmware's jitter
// filter. // filter.
// @param x integer // @param x integer
// @param y integer // @param y integer
// @lua-fn on_touch_move // @lua-fn onTouchMove?
// ---Fired when the finger lifts. // ---Fired when the finger lifts.
// @param x integer // @param x integer
// @param y integer // @param y integer
// @lua-fn on_touch_up // @lua-fn onTouchUp?
// ---Tap alias, fired on release like a click, after on_touch_up. // ---Tap alias, fired on release like a click, after onTouchUp.
// @param x integer // @param x integer
// @param y integer // @param y integer
// @lua-fn on_touch // @lua-fn onTouch?
+90
View File
@@ -0,0 +1,90 @@
// JSON for apps, provided by lua-cjson under native/src/vendor/cjson. It
// decodes straight onto the Lua stack rather than building a document first, so
// a response costs its text plus the table it becomes and nothing in between.
#include <lua/runtime.h>
#include <string>
extern "C" {
#include "lauxlib.h"
#include "lua.h"
int luaopen_cjson(lua_State* state);
}
namespace esp32lua {
namespace bindings {
namespace {
// lua-cjson defaults to 1000 and decodes by recursing on the C stack, which is
// sized for a server rather than a FreeRTOS task. Setting it through the
// module's own knob keeps the vendored source unpatched.
constexpr lua_Integer MAX_DEPTH = 32;
void setDepth(lua_State* state, const char* name) {
lua_getfield(state, -1, name);
lua_pushinteger(state, MAX_DEPTH);
lua_call(state, 1, 0);
}
int open(lua_State* state) {
luaopen_cjson(state);
setDepth(state, "decode_max_depth");
setDepth(state, "encode_max_depth");
return 1;
}
} // namespace
// package.preload rather than a global: the library provides this itself, so no
// firmware implements it and nothing on the SD card shadows it. An app that
// never requires it never pays for the module.
void registerJson(lua_State* state) {
luaL_getsubtable(state, LUA_REGISTRYINDEX, LUA_PRELOAD_TABLE);
lua_pushcfunction(state, open);
lua_setfield(state, -2, "cjson");
lua_pop(state, 1);
}
} // namespace bindings
namespace {
// Leaves cjson.<name> on the stack. Going through require() rather than a
// second luaopen_cjson keeps one module, and one configured depth, per state.
void pushCjson(lua_State* state, const char* name) {
lua_getglobal(state, "require");
lua_pushstring(state, "cjson");
lua_call(state, 1, 1);
lua_getfield(state, -1, name);
lua_remove(state, -2);
}
} // namespace
std::string encodeJson(lua_State* state, int index) {
const int value = lua_absindex(state, index);
pushCjson(state, "encode");
lua_pushvalue(state, value);
lua_call(state, 1, 1);
size_t length = 0;
const char* text = lua_tolstring(state, -1, &length);
const std::string json(text ? text : "", length);
lua_pop(state, 1);
return json;
}
bool decodeJson(lua_State* state, const std::string& json) {
if (lua_gettop(state) + 4 > LUAI_MAXSTACK)
return false;
pushCjson(state, "decode");
lua_pushlstring(state, json.data(), json.size());
if (lua_pcall(state, 1, 1, 0) != LUA_OK) {
lua_pop(state, 1);
return false;
}
return true;
}
} // namespace esp32lua
File diff suppressed because one or more lines are too long
+220 -20
View File
@@ -1,10 +1,14 @@
#pragma once #pragma once
// Painting the node tree through GuiProvider, so the same walk drives an LCD // Painting the node tree through GuiProvider, so the same walk drives an LCD
// and an e-ink panel. A dirty node paints itself and dirties its children, // and an e-ink panel. The dirty region is composited a band at a time into an
// because a parent's fill lands on top of whatever they drew; nothing tracks // offscreen buffer and pushed once, so a full-pane repaint -- a scroll -- moves
// sub-regions, and a widget that wants to repaint part of itself is a CUSTOM // smoothly instead of stalling the bus per primitive. The tree is the display
// node painting through the gui bindings. // list: each band re-walks it and paints the nodes it touches, no recorded
// command stream. A driver that offers no buffer falls back to painting the
// dirty nodes straight to the panel, which is what an e-ink panel wants anyway.
// A widget repainting part of itself is still a CUSTOM node painting through
// the gui bindings; a custom painter is re-invoked per band it spans.
#include <lua/layout.h> #include <lua/layout.h>
#include <lua/providers.h> #include <lua/providers.h>
@@ -12,8 +16,21 @@
namespace esp32lua { namespace esp32lua {
namespace ui { namespace ui {
// The node's own box, then the region of it being painted right now -- one band, or the
// whole node on the panel fallback. A painter that culls to the clip does its work once a
// frame instead of once per band it spans; one that ignores the extra arguments still
// paints correctly, because every write is clipped anyway.
typedef void (*CustomPainter)(void* context, uint16_t id, int x, int y, int w, typedef void (*CustomPainter)(void* context, uint16_t id, int x, int y, int w,
int h); int h, int clipX, int clipY, int clipW,
int clipH);
// Starting band height. How much contiguous heap exists depends on the app, the
// orientation and how fragmented the heap already is, so this is a ceiling the
// painter shrinks from rather than a size it assumes: a band that will not
// allocate is halved and retried, down to MIN_BAND, before the repaint falls
// back to the panel.
constexpr int BAND_HEIGHT = 48;
constexpr int MIN_BAND = 8;
class Painter { class Painter {
public: public:
@@ -23,22 +40,188 @@ public:
void* context = nullptr; void* context = nullptr;
void draw(uint16_t id) { void draw(uint16_t id) {
if (tree.nodes[id].flags & DIRTY) { Rect dirty;
paint(id); collectDirty(id, false, dirty, Box::unbounded());
tree.nodes[id].flags &= ~DIRTY; if (dirty.empty())
for (uint16_t c = tree.nodes[id].first; c != NONE; return;
c = tree.nodes[c].next) { if (!drawBanded(id, dirty))
tree.nodes[c].flags |= DIRTY; drawDirect(id);
} clearDirty(id);
}
for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next)
draw(c);
} }
private: private:
GuiProvider& gui; GuiProvider& gui;
Tree& tree; Tree& tree;
struct Rect {
int minX = 1 << 30, minY = 1 << 30, maxX = -(1 << 30), maxY = -(1 << 30);
bool empty() const { return maxX <= minX || maxY <= minY; }
void add(int x, int y, int w, int h) {
if (x < minX)
minX = x;
if (y < minY)
minY = y;
if (x + w > maxX)
maxX = x + w;
if (y + h > maxY)
maxY = y + h;
}
};
// An edge-bounded rectangle, which a Rect built by union cannot express: the
// clip narrows as the walk descends and has to start meaning "no limit".
struct Box {
int x0, y0, x1, y1;
static Box unbounded() {
const Box b = {-(1 << 30), -(1 << 30), 1 << 30, 1 << 30};
return b;
}
bool empty() const { return x1 <= x0 || y1 <= y0; }
Box clipTo(int x, int y, int w, int h) const {
Box b = {x > x0 ? x : x0, y > y0 ? y : y0, x + w < x1 ? x + w : x1,
y + h < y1 ? y + h : y1};
return b;
}
};
// The clip in force, so a custom node inside a scroll container narrows the
// container's box instead of replacing it and painting over the chrome when
// it restores.
Box clip = Box::unbounded();
Box pushClip(const Box& next) {
const Box previous = clip;
clip = next;
gui.setClip(next.x0, next.y0, next.x1 - next.x0, next.y1 - next.y0);
return previous;
}
void popClip(const Box& previous) {
clip = previous;
if (previous.x0 == -(1 << 30) && previous.y0 == -(1 << 30))
gui.clearClip();
else
gui.setClip(previous.x0, previous.y0, previous.x1 - previous.x0,
previous.y1 - previous.y0);
}
static bool overlaps(const Node& n, int x, int y, int w, int h) {
return n.x < x + w && n.x + n.w > x && n.y < y + h && n.y + n.h > y;
}
// The region that will repaint: a dirty node and every descendant, because a
// dirty parent's fill lands over its children. Computed before painting so a
// band knows its extent without the dirty flags it is about to clear.
//
// Bounded by the enclosing scroll containers, because a scrolled subtree is
// as tall as its content: unclipped, dirtying a list of sixty rows would ask
// for a dirty region thousands of pixels tall and band the whole of it.
void collectDirty(uint16_t id, bool ancestorDirty, Rect& acc,
const Box& bounds) {
const Node& n = tree.nodes[id];
const bool dirty = ancestorDirty || (n.flags & DIRTY);
if (dirty) {
const Box visible = bounds.clipTo(n.x, n.y, n.w, n.h);
if (!visible.empty())
acc.add(visible.x0, visible.y0, visible.x1 - visible.x0,
visible.y1 - visible.y0);
}
const Box inner =
tree.scrolls(id) ? bounds.clipTo(n.x, n.y, n.w, n.h) : bounds;
if (inner.empty())
return;
for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next)
collectDirty(c, dirty, acc, inner);
}
void clearDirty(uint16_t id) {
tree.nodes[id].flags &= ~DIRTY;
for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next)
clearDirty(c);
}
// Composite the dirty region band by band. Returns false without drawing when
// the first band cannot be allocated, so the caller paints to the panel
// instead. A band is cleared to the root background because its buffer starts
// undefined; the nodes then paint their own fills over it in tree order.
bool drawBanded(uint16_t root, const Rect& dirty) {
const int x = dirty.minX;
const int w = dirty.maxX - dirty.minX;
const int32_t background = tree.inherited(root, S_BG).bg;
bool any = false;
int band = BAND_HEIGHT;
int y = dirty.minY;
while (y < dirty.maxY) {
const int h = y + band > dirty.maxY ? dirty.maxY - y : band;
if (gui.beginBuffer(x, y, w, h)) {
any = true;
gui.clear(background);
paintBand(root, x, y, w, h);
gui.present();
y += h;
} else if (band > MIN_BAND) {
band /= 2; // too big for this heap: retry the same strip, smaller
} else if (any) {
paintBand(root, x, y, w, h); // mid-frame failure: draw this strip direct
y += h;
} else {
return false; // nothing fits: caller uses the panel path
}
}
return true;
}
// Paint every node overlapping the band, parent before child so fills sit
// under their contents. Unlike drawDirect this ignores the dirty flag: the
// band's buffer was just cleared, so everything visible in it must be redrawn.
void paintBand(uint16_t id, int x, int y, int w, int h) {
const Node& n = tree.nodes[id];
if (overlaps(n, x, y, w, h))
paint(id, x, y, w, h);
if (n.first == NONE)
return;
// A scroll container's children are laid out past its edges, so the subtree
// is scissored to the box on the way in. Without it a row half scrolled off
// the top paints over whatever sits above the container.
const bool scissor = tree.scrolls(id);
Box previous = clip;
if (scissor) {
const Box inner = clip.clipTo(n.x, n.y, n.w, n.h);
if (inner.empty())
return;
previous = pushClip(inner);
}
for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next)
paintBand(c, x, y, w, h);
if (scissor)
popClip(previous);
}
// Panel fallback: the original dirty-propagating walk, straight to the panel.
void drawDirect(uint16_t id) {
const Node& n = tree.nodes[id];
if (n.flags & DIRTY) {
paint(id, n.x, n.y, n.w, n.h);
for (uint16_t c = tree.nodes[id].first; c != NONE;
c = tree.nodes[c].next)
tree.nodes[c].flags |= DIRTY;
}
if (n.first == NONE)
return;
const bool scissor = tree.scrolls(id);
Box previous = clip;
if (scissor) {
const Box inner = clip.clipTo(n.x, n.y, n.w, n.h);
if (inner.empty())
return;
previous = pushClip(inner);
}
for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next)
drawDirect(c);
if (scissor)
popClip(previous);
}
// What a node sits on, which is not what it fills. Derived rather than // 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 // 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, // paints nothing, so its card blends into the dimmed content two levels up,
@@ -56,7 +239,7 @@ private:
return tree.inherited(root, S_BG).bg; return tree.inherited(root, S_BG).bg;
} }
void paint(uint16_t id) { void paint(uint16_t id, int clipX, int clipY, int clipW, int clipH) {
const Node& n = tree.nodes[id]; const Node& n = tree.nodes[id];
switch (n.type) { switch (n.type) {
case BUTTON: case BUTTON:
@@ -65,13 +248,29 @@ private:
case TEXT: case TEXT:
paintText(id); paintText(id);
break; break;
case CUSTOM: case CUSTOM: {
// Cleared first, because a custom painter draws what it wants and nothing // Cleared first, because a custom painter draws what it wants and nothing
// knows what it drew last time. // knows what it drew last time. Only the band's slice of the node is
gui.fillRect(n.x, n.y, n.w, n.h, tree.inherited(id, S_BG).bg); // cleared, because each band clears its own before repainting it.
const int x0 = n.x > clipX ? n.x : clipX;
const int y0 = n.y > clipY ? n.y : clipY;
const int nx1 = n.x + n.w, ny1 = n.y + n.h;
const int x1 = nx1 < clipX + clipW ? nx1 : clipX + clipW;
const int y1 = ny1 < clipY + clipH ? ny1 : clipY + clipH;
if (x1 <= x0 || y1 <= y0)
break;
// Scissored to the node, not just to the band: a custom painter draws what
// it likes and a band is usually taller than the node, so the overflow of
// a row half scrolled off the top would otherwise paint over the chrome
// above it. Culling cannot replace this -- the row is meant to be drawn,
// just cut off at the node's edge.
const Box previous = pushClip(clip.clipTo(x0, y0, x1 - x0, y1 - y0));
gui.fillRect(x0, y0, x1 - x0, y1 - y0, tree.inherited(id, S_BG).bg);
if (custom) if (custom)
custom(context, id, n.x, n.y, n.w, n.h); custom(context, id, n.x, n.y, n.w, n.h, x0, y0, x1 - x0, y1 - y0);
popClip(previous);
break; break;
}
default: default:
paintBox(id); paintBox(id);
break; break;
@@ -80,6 +279,7 @@ private:
paintFocus(id); paintFocus(id);
} }
void paintBox(uint16_t id) { void paintBox(uint16_t id) {
const Node& n = tree.nodes[id]; const Node& n = tree.nodes[id];
const Style* own = tree.styleOf(id); const Style* own = tree.styleOf(id);
+3 -5
View File
@@ -123,13 +123,11 @@ int Runtime::loadFile(lua_State* state) {
return 2; return 2;
} }
void Runtime::installLoader(const std::string& appDir) { // package.path is left to main.lua, which is loaded by absolute path and knows
// where its libraries and its apps are.
void Runtime::installLoader() {
lua_getglobal(state_, "package"); lua_getglobal(state_, "package");
const std::string path = appDir + "/?.lua;" + paths_.lib + "/?.lua";
lua_pushlstring(state_, path.data(), path.size());
lua_setfield(state_, -2, "path");
// Keep the preload searcher, drop the C loaders: they can only report // Keep the preload searcher, drop the C loaders: they can only report
// misleading errors about shared objects that were never there. // misleading errors about shared objects that were never there.
lua_getfield(state_, -1, "searchers"); lua_getfield(state_, -1, "searchers");
+107 -99
View File
@@ -11,12 +11,12 @@ namespace bindings {
void registerBle(lua_State* state); void registerBle(lua_State* state);
void registerButtons(lua_State* state); void registerButtons(lua_State* state);
void registerFs(lua_State* state); void registerFs(lua_State* state);
void registerGui(lua_State* state);
void registerHttp(lua_State* state); void registerHttp(lua_State* state);
void registerJson(lua_State* state);
void registerLog(lua_State* state); void registerLog(lua_State* state);
void registerNode(lua_State* state); void registerScreen(lua_State* state);
void registerSettings(lua_State* state);
void registerSys(lua_State* state); void registerSys(lua_State* state);
void registerTree(lua_State* state);
void registerTimer(lua_State* state); void registerTimer(lua_State* state);
void registerTouch(lua_State* state); void registerTouch(lua_State* state);
void registerWifi(lua_State* state); void registerWifi(lua_State* state);
@@ -26,10 +26,13 @@ namespace {
// App routes are relative and stay inside the apps root, so a traversal // App routes are relative and stay inside the apps root, so a traversal
// component is a hard no. // component is a hard no.
bool isSafeRoute(const std::string& path) { // Absolute, and no component that could climb out of the card. Apps are
if (path.empty() || path[0] == '/') // trusted, so this is a guard against a mistake rather than an attacker -- but
// it is the one place a path from Lua becomes a file the runtime opens.
bool isSafePath(const std::string& path) {
if (path.empty() || path[0] != '/')
return false; return false;
size_t start = 0; size_t start = 1;
while (start <= path.size()) { while (start <= path.size()) {
const size_t end = path.find('/', start); const size_t end = path.find('/', start);
const std::string part = path.substr( const std::string part = path.substr(
@@ -45,17 +48,16 @@ bool isSafeRoute(const std::string& path) {
} // namespace } // namespace
Runtime::Runtime(const Providers& providers, const Paths& paths) Runtime::Runtime(const Providers& providers) : providers_(providers) {}
: providers_(providers), paths_(paths) {}
Runtime::~Runtime() { close(); } Runtime::~Runtime() { close(); }
bool Runtime::open() { bool Runtime::open() {
if (state_) if (state_)
return true; return true;
if (!providers_.log || !providers_.settings || !providers_.sys || if (!providers_.log || !providers_.sys || !providers_.fs ||
!providers_.fs || !providers_.gui || !providers_.http || !providers_.http || !providers_.timer || !providers_.wifi ||
!providers_.timer || !providers_.wifi || !providers_.ble) { !providers_.ble) {
return false; return false;
} }
@@ -65,19 +67,21 @@ bool Runtime::open() {
*static_cast<Runtime**>(lua_getextraspace(state_)) = this; *static_cast<Runtime**>(lua_getextraspace(state_)) = this;
luaL_openlibs(state_); luaL_openlibs(state_);
// Primary Namespaces
bindings::registerBle(state_); bindings::registerBle(state_);
bindings::registerFs(state_); bindings::registerFs(state_);
bindings::registerGui(state_);
bindings::registerHttp(state_); bindings::registerHttp(state_);
bindings::registerJson(state_);
bindings::registerLog(state_); bindings::registerLog(state_);
bindings::registerNode(state_);
bindings::registerSettings(state_);
bindings::registerSys(state_); bindings::registerSys(state_);
bindings::registerTimer(state_); bindings::registerTimer(state_);
bindings::registerWifi(state_); bindings::registerWifi(state_);
// Feature namespaces extend the tables the core registrations just created, // Feature Namespaces
// so they always follow them. if (providers_.gui) {
bindings::registerScreen(state_);
bindings::registerTree(state_);
}
if (providers_.touch) if (providers_.touch)
bindings::registerTouch(state_); bindings::registerTouch(state_);
if (providers_.buttons) if (providers_.buttons)
@@ -91,11 +95,9 @@ void Runtime::close() {
cancelAllTimers(); cancelAllTimers();
lua_close(state_); lua_close(state_);
state_ = nullptr; state_ = nullptr;
// Node handles mean nothing to the next lua_State, so an app that inherited mainRef_ = 0;
// the previous tree would build onto its nodes.
tree_.reset(); tree_.reset();
appPath_.clear(); appPath_.clear();
appTitle_.clear();
} }
Runtime::Batch::Batch(Runtime& runtime) : runtime_(runtime) { Runtime::Batch::Batch(Runtime& runtime) : runtime_(runtime) {
@@ -103,18 +105,34 @@ Runtime::Batch::Batch(Runtime& runtime) : runtime_(runtime) {
} }
Runtime::Batch::~Batch() { Runtime::Batch::~Batch() {
if (--runtime_.batchDepth_ == 0) if (--runtime_.batchDepth_ == 0 && runtime_.providers_.gui)
runtime_.providers_.gui->commit(); runtime_.providers_.gui->commit();
} }
bool Runtime::beginCall(const char* name) { bool Runtime::beginCall(const char* name) {
lua_getglobal(state_, name); if (!mainRef_)
return false;
lua_rawgeti(state_, LUA_REGISTRYINDEX, mainRef_);
lua_getfield(state_, -1, name);
lua_remove(state_, -2);
if (lua_isfunction(state_, -1)) if (lua_isfunction(state_, -1))
return true; return true;
lua_pop(state_, 1); lua_pop(state_, 1);
return false; return false;
} }
// Calls a chunk or handler that leaves one value on the stack; the caller owns
// it.
bool Runtime::finishCallValue(const char* name) {
if (lua_pcall(state_, 0, 1, 0) == LUA_OK)
return true;
const char* message = lua_tostring(state_, -1);
providers_.log->write(LogLevel::Error, std::string(name) + ": " +
(message ? message : "failed"));
lua_pop(state_, 1);
return false;
}
bool Runtime::finishCall(const char* name, int argc) { bool Runtime::finishCall(const char* name, int argc) {
if (lua_pcall(state_, argc, 0, 0) == LUA_OK) if (lua_pcall(state_, argc, 0, 0) == LUA_OK)
return true; return true;
@@ -125,18 +143,16 @@ bool Runtime::finishCall(const char* name, int argc) {
return false; return false;
} }
// @lua-global core/runtime // @lua-app App core/runtime
// @lua-preamble -- Runtime layout: // @lua-preamble -- The firmware loads the path it was booted with into every
// @lua-preamble -- /.lua/apps/<AppId>/main.lua application entry // fresh state and calls
// point // @lua-preamble -- these on the table it returns. Where apps live, what
// @lua-preamble -- /.lua/apps/<AppId>/<Subapp>/main.lua nested route, // surrounds them and which of
// omitted from the launcher // @lua-preamble -- these an app itself sees are all that file's to decide,
// @lua-preamble -- /.lua/data/<AppId>/ persistent app // which is why an app
// data, preserved across updates // @lua-preamble -- composes the classes for the features it handles:
// @lua-preamble -- /.lua/lib/<module>.lua shared require() // @lua-preamble --
// modules // @lua-preamble -- ---@class PaintApp : App, TouchHandlers
// @lua-preamble -- require() also searches the running application's
// directory
// @lua-preamble -- // @lua-preamble --
// @lua-preamble -- The firmware does not clear the frame before calling draw(), // @lua-preamble -- The firmware does not clear the frame before calling draw(),
// and commits changed // and commits changed
@@ -145,24 +161,34 @@ bool Runtime::finishCall(const char* name, int argc) {
// @lua-preamble -- Timer callbacks are registered directly with // @lua-preamble -- Timer callbacks are registered directly with
// timer.after/every. // timer.after/every.
// ---Required. Runs once before the first draw; failing here stops the app. // ---Required. Mounts whatever the arguments describe; failing here leaves no
// @param arg string|nil The string passed to sys.launch or sys.replace. // app running.
// @lua-fn init // @param args table|nil The table passed to sys.startApp, carried across the
bool Runtime::callInit(const std::string& arg) { // teardown as JSON.
// @lua-fn start
bool Runtime::callStart(const std::string& argsJson) {
const Batch batch(*this); const Batch batch(*this);
if (!beginCall("init")) { if (!beginCall("start")) {
providers_.log->write(LogLevel::Error, "init: the app defines none"); providers_.log->write(LogLevel::Error,
"start: the entry file defines none");
return false; return false;
} }
lua_pushlstring(state_, arg.data(), arg.size()); if (argsJson.empty()) {
return finishCall("init", 1); lua_pushnil(state_);
} else if (!decodeJson(state_, argsJson)) {
providers_.log->write(LogLevel::Error,
"start: cannot decode arguments: " + argsJson);
lua_pop(state_, 1);
return false;
}
return finishCall("start", 1);
} }
// ---Optional frame loop, called once after init and then at most 30 FPS, best // ---Optional frame loop, called once after start and then at most 30 FPS, best
// effort. // effort.
// @param deltaMs integer Monotonic milliseconds since the previous draw; zero // @param deltaMs integer Monotonic milliseconds since the previous draw; zero
// on the first. // on the first.
// @lua-fn draw // @lua-fn draw?
void Runtime::callDraw(int32_t deltaMs) { void Runtime::callDraw(int32_t deltaMs) {
const Batch batch(*this); const Batch batch(*this);
if (!beginCall("draw")) if (!beginCall("draw"))
@@ -180,16 +206,16 @@ void Runtime::callTouch(TouchPhase phase, int32_t x, int32_t y) {
const Batch batch(*this); const Batch batch(*this);
const char* name = const char* name =
phase == TouchPhase::Down phase == TouchPhase::Down
? "on_touch_down" ? "onTouchDown"
: (phase == TouchPhase::Move ? "on_touch_move" : "on_touch_up"); : (phase == TouchPhase::Move ? "onTouchMove" : "onTouchUp");
for (int pass = 0; pass < 2; pass++) { for (int pass = 0; pass < 2; pass++) {
// The tap alias is ordering, not policy: a release always fires on_touch_up // The tap alias is ordering, not policy: a release always fires onTouchUp
// and then on_touch, so both firmwares agree without either of them // and then onTouch, so both firmwares agree without either of them
// deciding anything. // deciding anything.
if (pass == 1) { if (pass == 1) {
if (phase != TouchPhase::Up) if (phase != TouchPhase::Up)
return; return;
name = "on_touch"; name = "onTouch";
} }
if (!beginCall(name)) if (!beginCall(name))
continue; continue;
@@ -206,12 +232,12 @@ void Runtime::callButton(const std::string& button, bool pressed) {
return; return;
} }
const Batch batch(*this); const Batch batch(*this);
const char* name = pressed ? "on_button_down" : "on_button_up"; const char* name = pressed ? "onButtonDown" : "onButtonUp";
for (int pass = 0; pass < 2; pass++) { for (int pass = 0; pass < 2; pass++) {
if (pass == 1) { if (pass == 1) {
if (pressed) if (pressed)
return; return;
name = "on_button"; name = "onButton";
} }
if (!beginCall(name)) if (!beginCall(name))
continue; continue;
@@ -275,14 +301,9 @@ void Runtime::cancelAllTimers() {
timers_.clear(); timers_.clear();
} }
std::string Runtime::appId() const {
const size_t slash = appPath_.find('/');
return slash == std::string::npos ? appPath_ : appPath_.substr(0, slash);
}
std::string Runtime::appDataPath() const { return paths_.data + "/" + appId(); }
bool Runtime::hasFeature(const std::string& feature) const { bool Runtime::hasFeature(const std::string& feature) const {
if (feature == "screen")
return providers_.gui != nullptr;
if (feature == "touch") if (feature == "touch")
return providers_.touch != nullptr; return providers_.touch != nullptr;
if (feature == "buttons") if (feature == "buttons")
@@ -290,8 +311,8 @@ bool Runtime::hasFeature(const std::string& feature) const {
return false; return false;
} }
bool Runtime::startApp(const std::string& path, const std::string& arg) { bool Runtime::startApp(const std::string& path, const std::string& argsJson) {
if (!isSafeRoute(path)) { if (!isSafePath(path)) {
providers_.log->write(LogLevel::Error, "refusing to start '" + path + "'"); providers_.log->write(LogLevel::Error, "refusing to start '" + path + "'");
return false; return false;
} }
@@ -300,61 +321,48 @@ bool Runtime::startApp(const std::string& path, const std::string& arg) {
if (!open()) if (!open())
return false; return false;
appPath_ = path; appPath_ = path;
appTitle_ = appId();
const std::string directory = paths_.apps + "/" + path; installLoader();
installLoader(directory); // The entry file runs first and start() mounts whatever it decides to, so an
if (!loadScript(directory + "/main.lua")) { // app that fails either way leaves nothing behind.
providers_.log->write(LogLevel::Error, lua_tostring(state_, -1) if (!loadMain(path) || !callStart(argsJson)) {
? lua_tostring(state_, -1)
: "load failed");
close();
return false;
}
// The chunk body runs first, then init(), so an app that fails either way
// leaves nothing behind.
if (!finishCall("main.lua", 0) || !callInit(arg)) {
close(); close();
return false; return false;
} }
return true; return true;
} }
void Runtime::requestLaunch(const std::string& path, const std::string& arg, bool Runtime::loadMain(const std::string& path) {
bool replace) { if (!loadScript(path)) {
pending_.kind = replace ? Pending::Replace : Pending::Launch; providers_.log->write(LogLevel::Error, lua_tostring(state_, -1)
pending_.route.path = path; ? lua_tostring(state_, -1)
pending_.route.arg = arg; : "cannot load " + path);
return false;
}
if (!finishCallValue(path.c_str()))
return false;
if (!lua_istable(state_, -1)) {
providers_.log->write(LogLevel::Error, path + " returned no table");
lua_pop(state_, 1);
return false;
}
mainRef_ = luaL_ref(state_, LUA_REGISTRYINDEX);
return true;
} }
void Runtime::requestBack() { void Runtime::requestStart(const std::string& path,
pending_.kind = Pending::Back; const std::string& argsJson) {
pending_.route = Route(); pending_.pending = true;
pending_.path = path;
pending_.argsJson = argsJson;
} }
bool Runtime::applyPendingNavigation() { bool Runtime::applyPendingNavigation() {
const Pending pending = pending_; const Pending pending = pending_;
pending_ = Pending(); pending_ = Pending();
if (pending.kind == Pending::None) if (!pending.pending)
return hasApp(); return hasApp();
return startApp(pending.path, pending.argsJson);
if (pending.kind == Pending::Back) {
// An empty history means the launcher, which is an app like any other.
Route target;
target.path = paths_.home;
if (!history_.empty()) {
target = history_.back();
history_.pop_back();
}
return startApp(target.path, target.arg);
}
if (pending.kind == Pending::Launch && hasApp()) {
Route current;
current.path = appPath_;
history_.push_back(current);
}
return startApp(pending.route.path, pending.route.arg);
} }
Runtime* Runtime::from(lua_State* state) { Runtime* Runtime::from(lua_State* state) {
+20
View File
@@ -0,0 +1,20 @@
Copyright (c) 2010-2012 Mark Pulford <mark@kyne.com.au>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+211
View File
@@ -0,0 +1,211 @@
/* fpconv - Floating point conversion routines
*
* Copyright (c) 2011-2012 Mark Pulford <mark@kyne.com.au>
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/* JSON uses a '.' decimal separator. strtod() / sprintf() under C libraries
* with locale support will break when the decimal separator is a comma.
*
* fpconv_* will around these issues with a translation buffer if required.
*/
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#include "fpconv.h"
/* Workaround for MSVC */
#ifdef _MSC_VER
#define inline __inline
#define snprintf sprintf_s
#endif
/* Lua CJSON assumes the locale is the same for all threads within a
* process and doesn't change after initialisation.
*
* This avoids the need for per thread storage or expensive checks
* for call. */
static char locale_decimal_point = '.';
/* In theory multibyte decimal_points are possible, but
* Lua CJSON only supports UTF-8 and known locales only have
* single byte decimal points ([.,]).
*
* localconv() may not be thread safe (=>crash), and nl_langinfo() is
* not supported on some platforms. Use sprintf() instead - if the
* locale does change, at least Lua CJSON won't crash. */
static void fpconv_update_locale(void)
{
char buf[8];
snprintf(buf, sizeof(buf), "%g", 0.5);
/* Failing this test might imply the platform has a buggy dtoa
* implementation or wide characters */
if (buf[0] != '0' || buf[2] != '5' || buf[3] != 0) {
fprintf(stderr, "Error: wide characters found or printf() bug.");
abort();
}
locale_decimal_point = buf[1];
}
/* Check for a valid number character: [-+0-9a-yA-Y.]
* Eg: -0.6e+5, infinity, 0xF0.F0pF0
*
* Used to find the probable end of a number. It doesn't matter if
* invalid characters are counted - strtod() will find the valid
* number if it exists. The risk is that slightly more memory might
* be allocated before a parse error occurs. */
static inline int valid_number_character(char ch)
{
char lower_ch;
if ('0' <= ch && ch <= '9')
return 1;
if (ch == '-' || ch == '+' || ch == '.')
return 1;
/* Hex digits, exponent (e), base (p), "infinity",.. */
lower_ch = ch | 0x20;
if ('a' <= lower_ch && lower_ch <= 'y')
return 1;
return 0;
}
/* Calculate the size of the buffer required for a strtod locale
* conversion. */
static int strtod_buffer_size(const char *s)
{
const char *p = s;
while (valid_number_character(*p))
p++;
return p - s;
}
/* Similar to strtod(), but must be passed the current locale's decimal point
* character. Guaranteed to be called at the start of any valid number in a string */
double fpconv_strtod(const char *nptr, char **endptr)
{
char localbuf[FPCONV_G_FMT_BUFSIZE];
char *buf, *endbuf, *dp;
int buflen;
double value;
/* System strtod() is fine when decimal point is '.' */
if (locale_decimal_point == '.')
return strtod(nptr, endptr);
buflen = strtod_buffer_size(nptr);
if (!buflen) {
/* No valid characters found, standard strtod() return */
*endptr = (char *)nptr;
return 0;
}
/* Duplicate number into buffer */
if (buflen >= FPCONV_G_FMT_BUFSIZE) {
/* Handle unusually large numbers */
buf = (char *)malloc(buflen + 1);
if (!buf) {
fprintf(stderr, "Out of memory");
abort();
}
} else {
/* This is the common case.. */
buf = localbuf;
}
memcpy(buf, nptr, buflen);
buf[buflen] = 0;
/* Update decimal point character if found */
dp = strchr(buf, '.');
if (dp)
*dp = locale_decimal_point;
value = strtod(buf, &endbuf);
*endptr = (char *)&nptr[endbuf - buf];
if (buflen >= FPCONV_G_FMT_BUFSIZE)
free(buf);
return value;
}
/* "fmt" must point to a buffer of at least 6 characters */
static void set_number_format(char *fmt, int precision)
{
int d1, d2, i;
assert(1 <= precision && precision <= 16);
/* Create printf format (%.14g) from precision */
d1 = precision / 10;
d2 = precision % 10;
fmt[0] = '%';
fmt[1] = '.';
i = 2;
if (d1) {
fmt[i++] = '0' + d1;
}
fmt[i++] = '0' + d2;
fmt[i++] = 'g';
fmt[i] = 0;
}
/* Assumes there is always at least 32 characters available in the target buffer */
int fpconv_g_fmt(char *str, double num, int precision)
{
char buf[FPCONV_G_FMT_BUFSIZE];
char fmt[6];
int len;
char *b;
set_number_format(fmt, precision);
/* Pass through when decimal point character is dot. */
if (locale_decimal_point == '.')
return snprintf(str, FPCONV_G_FMT_BUFSIZE, fmt, num);
/* snprintf() to a buffer then translate for other decimal point characters */
len = snprintf(buf, FPCONV_G_FMT_BUFSIZE, fmt, num);
/* Copy into target location. Translate decimal point if required */
b = buf;
do {
*str++ = (*b == locale_decimal_point ? '.' : *b);
} while(*b++);
return len;
}
void fpconv_init(void)
{
fpconv_update_locale();
}
/* vi:ai et sw=4 ts=4:
*/
+32
View File
@@ -0,0 +1,32 @@
/* Lua CJSON floating point conversion routines */
/* Buffer required to store the largest string representation of a double.
*
* Longest double printed with %.14g is 21 characters long:
* -1.7976931348623e+308 */
# define FPCONV_G_FMT_BUFSIZE 32
#ifdef USE_INTERNAL_FPCONV
#ifdef MULTIPLE_THREADS
#include "dtoa_config.h"
#include <unistd.h>
static inline void fpconv_init()
{
// Add one to try and avoid core id multiplier alignment
set_max_dtoa_threads((sysconf(_SC_NPROCESSORS_CONF) + 1) * 3);
}
#else
static inline void fpconv_init()
{
/* Do nothing - not required */
}
#endif
#else
extern void fpconv_init(void);
#endif
extern int fpconv_g_fmt(char*, double, int);
extern double fpconv_strtod(const char*, char**);
/* vi:ai et sw=4 ts=4:
*/
File diff suppressed because it is too large Load Diff
+199
View File
@@ -0,0 +1,199 @@
/* strbuf - String buffer routines
*
* Copyright (c) 2010-2012 Mark Pulford <mark@kyne.com.au>
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
#include <stdio.h>
#include <stdlib.h>
#include <stdarg.h>
#include <string.h>
#include <stdint.h>
#include "strbuf.h"
static void die(const char *fmt, ...)
{
va_list arg;
va_start(arg, fmt);
vfprintf(stderr, fmt, arg);
va_end(arg);
fprintf(stderr, "\n");
abort();
}
void strbuf_init(strbuf_t *s, size_t len)
{
size_t size;
if (!len)
size = STRBUF_DEFAULT_SIZE;
else
size = len + 1;
if (size < len)
die("Overflow, len: %zu", len);
s->buf = NULL;
s->size = size;
s->length = 0;
s->dynamic = 0;
s->reallocs = 0;
s->debug = 0;
s->buf = (char *)malloc(size);
if (!s->buf)
die("Out of memory");
strbuf_ensure_null(s);
}
strbuf_t *strbuf_new(size_t len)
{
strbuf_t *s;
s = (strbuf_t*)malloc(sizeof(strbuf_t));
if (!s)
die("Out of memory");
strbuf_init(s, len);
/* Dynamic strbuf allocation / deallocation */
s->dynamic = 1;
return s;
}
static inline void debug_stats(strbuf_t *s)
{
if (s->debug) {
fprintf(stderr, "strbuf(%p) reallocs: %d, length: %zd, size: %zd\n",
(void *) s, s->reallocs, s->length, s->size);
}
}
/* If strbuf_t has not been dynamically allocated, strbuf_free() can
* be called any number of times strbuf_init() */
void strbuf_free(strbuf_t *s)
{
debug_stats(s);
if (s->buf) {
free(s->buf);
s->buf = NULL;
}
if (s->dynamic)
free(s);
}
char *strbuf_free_to_string(strbuf_t *s, size_t *len)
{
char *buf;
debug_stats(s);
strbuf_ensure_null(s);
buf = s->buf;
if (len)
*len = s->length;
if (s->dynamic)
free(s);
return buf;
}
static size_t calculate_new_size(strbuf_t *s, size_t len)
{
size_t reqsize, newsize;
if (len <= 0)
die("BUG: Invalid strbuf length requested");
/* Ensure there is room for optional NULL termination */
reqsize = len + 1;
if (reqsize < len)
die("Overflow, len: %zu", len);
/* If the user has requested to shrink the buffer, do it exactly */
if (s->size > reqsize)
return reqsize;
newsize = s->size;
if (reqsize >= SIZE_MAX / 2) {
newsize = reqsize;
} else {
/* Exponential sizing */
while (newsize < reqsize)
newsize *= 2;
}
if (newsize < reqsize)
die("BUG: strbuf length would overflow, len: %zu", len);
return newsize;
}
/* Ensure strbuf can handle a string length bytes long (ignoring NULL
* optional termination). */
void strbuf_resize(strbuf_t *s, size_t len)
{
size_t newsize;
newsize = calculate_new_size(s, len);
if (s->debug > 1) {
fprintf(stderr, "strbuf(%p) resize: %zd => %zd\n",
(void *) s, s->size, newsize);
}
s->size = newsize;
s->buf = (char *)realloc(s->buf, s->size);
if (!s->buf)
die("Out of memory, len: %zu", len);
s->reallocs++;
}
void strbuf_append_string(strbuf_t *s, const char *str)
{
int i;
size_t space;
space = strbuf_empty_length(s);
for (i = 0; str[i]; i++) {
if (space < 1) {
strbuf_resize(s, s->length + 1);
space = strbuf_empty_length(s);
}
s->buf[s->length] = str[i];
s->length++;
space--;
}
}
/* vi:ai et sw=4 ts=4:
*/
+157
View File
@@ -0,0 +1,157 @@
/* strbuf - String buffer routines
*
* Copyright (c) 2010-2012 Mark Pulford <mark@kyne.com.au>
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
#include <stdlib.h>
#include <stdarg.h>
/* Workaround for MSVC */
#ifdef _MSC_VER
#define inline __inline
#endif
/* Size: Total bytes allocated to *buf
* Length: String length, excluding optional NULL terminator.
* Dynamic: True if created via strbuf_new()
*/
typedef struct {
char *buf;
size_t size;
size_t length;
int dynamic;
int reallocs;
int debug;
} strbuf_t;
#ifndef STRBUF_DEFAULT_SIZE
#define STRBUF_DEFAULT_SIZE 1023
#endif
/* Initialise */
extern strbuf_t *strbuf_new(size_t len);
extern void strbuf_init(strbuf_t *s, size_t len);
/* Release */
extern void strbuf_free(strbuf_t *s);
extern char *strbuf_free_to_string(strbuf_t *s, size_t *len);
/* Management */
extern void strbuf_resize(strbuf_t *s, size_t len);
static size_t strbuf_empty_length(strbuf_t *s);
static size_t strbuf_length(strbuf_t *s);
static char *strbuf_string(strbuf_t *s, size_t *len);
static void strbuf_ensure_empty_length(strbuf_t *s, size_t len);
static char *strbuf_empty_ptr(strbuf_t *s);
static void strbuf_extend_length(strbuf_t *s, size_t len);
static void strbuf_set_length(strbuf_t *s, int len);
/* Update */
static void strbuf_append_mem(strbuf_t *s, const char *c, size_t len);
extern void strbuf_append_string(strbuf_t *s, const char *str);
static void strbuf_append_char(strbuf_t *s, const char c);
static void strbuf_ensure_null(strbuf_t *s);
/* Reset string for before use */
static inline void strbuf_reset(strbuf_t *s)
{
s->length = 0;
}
static inline int strbuf_allocated(strbuf_t *s)
{
return s->buf != NULL;
}
/* Return bytes remaining in the string buffer
* Ensure there is space for a NULL terminator. */
static inline size_t strbuf_empty_length(strbuf_t *s)
{
return s->size - s->length - 1;
}
static inline void strbuf_ensure_empty_length(strbuf_t *s, size_t len)
{
if (len > strbuf_empty_length(s))
strbuf_resize(s, s->length + len);
}
static inline char *strbuf_empty_ptr(strbuf_t *s)
{
return s->buf + s->length;
}
static inline void strbuf_set_length(strbuf_t *s, int len)
{
s->length = len;
}
static inline void strbuf_extend_length(strbuf_t *s, size_t len)
{
s->length += len;
}
static inline size_t strbuf_length(strbuf_t *s)
{
return s->length;
}
static inline void strbuf_append_char(strbuf_t *s, const char c)
{
strbuf_ensure_empty_length(s, 1);
s->buf[s->length++] = c;
}
static inline void strbuf_append_char_unsafe(strbuf_t *s, const char c)
{
s->buf[s->length++] = c;
}
static inline void strbuf_append_mem(strbuf_t *s, const char *c, size_t len)
{
strbuf_ensure_empty_length(s, len);
memcpy(s->buf + s->length, c, len);
s->length += len;
}
static inline void strbuf_append_mem_unsafe(strbuf_t *s, const char *c, size_t len)
{
memcpy(s->buf + s->length, c, len);
s->length += len;
}
static inline void strbuf_ensure_null(strbuf_t *s)
{
s->buf[s->length] = 0;
}
static inline char *strbuf_string(strbuf_t *s, size_t *len)
{
if (len)
*len = s->length;
return s->buf;
}
/* vi:ai et sw=4 ts=4:
*/
+32 -25
View File
@@ -23,28 +23,19 @@ struct Log : LogProvider {
} }
}; };
struct Settings : SettingsProvider {
int32_t degrees = 0;
std::string tz = "UTC0";
int32_t rotation() const override { return degrees; }
Status setRotation(int32_t value) override {
degrees = value;
return Status::success();
}
std::string timezone() const override { return tz; }
Status setTimezone(const std::string& value) override {
tz = value;
return Status::success();
}
};
struct Sys : SysProvider { struct Sys : SysProvider {
std::string tz = "UTC0";
int32_t millis() const override { return 1234; } int32_t millis() const override { return 1234; }
MemoryInfo memory() const override { MemoryInfo memory() const override {
const MemoryInfo info = {100, 200, 50}; const MemoryInfo info = {100, 200, 50};
return info; return info;
} }
bool isClockSynced() const override { return true; } bool isClockSynced() const override { return true; }
std::string timezone() const override { return tz; }
Status setTimezone(const std::string& value) override {
tz = value;
return Status::success();
}
}; };
struct Fs : FsProvider { struct Fs : FsProvider {
@@ -148,7 +139,7 @@ struct Fs : FsProvider {
struct Gui : GuiProvider { struct Gui : GuiProvider {
std::string trace; std::string trace;
int32_t degrees = 0; int32_t degrees = 0;
bool fullscreen = false; std::string themeName = "light";
bool gradient = false; bool gradient = false;
FontIds fonts() const override { FontIds fonts() const override {
@@ -158,11 +149,24 @@ struct Gui : GuiProvider {
int32_t width() const override { return 320; } int32_t width() const override { return 320; }
int32_t height() const override { return 240; } int32_t height() const override { return 240; }
int32_t rotation() const override { return degrees; } int32_t rotation() const override { return degrees; }
void setRotation(int32_t value) override { degrees = value; } Status setRotation(int32_t value) override {
degrees = value;
return Status::success();
}
std::string theme() const override { return themeName; }
Status setTheme(const std::string& value) override {
themeName = value;
return Status::success();
}
int32_t color(int32_t r, int32_t g, int32_t b) const override { int32_t color(int32_t r, int32_t g, int32_t b) const override {
return (r << 16) | (g << 8) | b; return (r << 16) | (g << 8) | b;
} }
void clear(int32_t) override { trace += "clear;"; } void clear(int32_t) override { trace += "clear;"; }
void setClip(int32_t x, int32_t y, int32_t w, int32_t h) override {
trace += "clip(" + std::to_string(x) + "," + std::to_string(y) + "," +
std::to_string(w) + "," + std::to_string(h) + ");";
}
void clearClip() override { trace += "unclip;"; }
void fillRect(int32_t, int32_t, int32_t, int32_t, int32_t) override { void fillRect(int32_t, int32_t, int32_t, int32_t, int32_t) override {
trace += "fillRect;"; trace += "fillRect;";
} }
@@ -189,7 +193,6 @@ struct Gui : GuiProvider {
trace += border ? ",border" : ",-"; trace += border ? ",border" : ",-";
trace += ");"; trace += ");";
} }
void setFullscreen(bool on) override { fullscreen = on; }
// Stands in for an e-ink panel, where a second commit is a second visible // Stands in for an e-ink panel, where a second commit is a second visible
// refresh. // refresh.
void commit() override { commits++; } void commit() override { commits++; }
@@ -273,9 +276,10 @@ struct Wifi : WifiProvider {
}; };
struct Ble : BleProvider { struct Ble : BleProvider {
int32_t duration = 0;
std::string value; std::string value;
bool initialized = false; bool initialized = false;
bool observing = false;
BleFilter filter;
Status init(const std::string*) override { Status init(const std::string*) override {
initialized = true; initialized = true;
@@ -283,12 +287,17 @@ struct Ble : BleProvider {
} }
void deinit() override { initialized = false; } void deinit() override { initialized = false; }
bool isInitialized() const override { return initialized; } bool isInitialized() const override { return initialized; }
Status scan(int32_t durationMs, std::vector<BleDevice>& devices) override { Status observe(const BleFilter& next) override {
duration = durationMs; filter = next;
const BleDevice device = {"tag", "aa:bb", -60}; observing = true;
devices.push_back(device);
return Status::success(); return Status::success();
} }
void unobserve() override { observing = false; }
bool isObserving() const override { return observing; }
void observed(std::vector<BleObservation>& out) override {
const BleObservation device = {"aa:bb", "tag", -60, "payload", 1234};
out.push_back(device);
}
Status connect(const std::string&) override { return Status::success(); } Status connect(const std::string&) override { return Status::success(); }
void disconnect() override {} void disconnect() override {}
bool isConnected() const override { return true; } bool isConnected() const override { return true; }
@@ -353,7 +362,6 @@ struct Buttons : ButtonsProvider {
// Every provider a Runtime needs, so a test names only what it asserts on. // Every provider a Runtime needs, so a test names only what it asserts on.
struct Bench { struct Bench {
Log log; Log log;
Settings settings;
Sys sys; Sys sys;
Fs fs; Fs fs;
Gui gui; Gui gui;
@@ -367,7 +375,6 @@ struct Bench {
Providers providers() { Providers providers() {
Providers providers; Providers providers;
providers.log = &log; providers.log = &log;
providers.settings = &settings;
providers.sys = &sys; providers.sys = &sys;
providers.fs = &fs; providers.fs = &fs;
providers.gui = &gui; providers.gui = &gui;
+256 -124
View File
@@ -37,13 +37,17 @@ int main() {
assert(bench.log.level == esp32lua::LogLevel::Info); assert(bench.log.level == esp32lua::LogLevel::Info);
assert(bench.log.message == "shared runtime"); assert(bench.log.message == "shared runtime");
run(state, "assert(settings.getRotation() == 0)\n" run(state, "assert(sys.hasFeature('screen'))\n"
"assert(settings.setRotation(90))\n" "assert(screen.getRotation() == 0)\n"
"assert(settings.getTimezone() == 'UTC0')\n" "assert(screen.setRotation(90))\n"
"assert(settings.setTimezone('EST5EDT'))"); "assert(screen.getTheme() == 'light')\n"
assert(bench.settings.degrees == 90); "assert(screen.setTheme('dark'))\n"
assert(bench.settings.tz == "EST5EDT"); "assert(sys.getTimezone() == 'UTC0')\n"
expectError(state, "settings.setRotation(45)"); "assert(sys.setTimezone('EST5EDT'))");
assert(bench.gui.degrees == 90);
assert(bench.gui.themeName == "dark");
assert(bench.sys.tz == "EST5EDT");
expectError(state, "screen.setRotation(45)");
run(state, "assert(sys.getAPIVersion() == 1)\n" run(state, "assert(sys.getAPIVersion() == 1)\n"
"assert(sys.hasFeature('touch') and sys.hasFeature('buttons'))\n" "assert(sys.hasFeature('touch') and sys.hasFeature('buttons'))\n"
@@ -66,17 +70,17 @@ int main() {
assert(bench.fs.written.size() == 3); assert(bench.fs.written.size() == 3);
expectError(state, "fs.readFile('/notes.txt', 999999)"); expectError(state, "fs.readFile('/notes.txt', 999999)");
run(state, "assert(gui.getWidth() == 320 and gui.getHeight() == 240)\n" run(state, "assert(screen.getWidth() == 320 and screen.getHeight() == 240)\n"
"assert(gui.FONT_UI == 2 and gui.STYLE_BOLD == 1)\n" "assert(screen.FONT_UI == 2 and screen.STYLE_BOLD == 1)\n"
"assert(gui.color(255, 0, 0) == 0xFF0000)\n" "assert(screen.color(255, 0, 0) == 0xFF0000)\n"
"gui.setRotation(180)\n" "assert(screen.setRotation(180))\n"
"gui.clear()\n" "screen.clear()\n"
"gui.fillPolygon({1, 2, 3}, {4, 5, 6}, 0)\n" "screen.fillPolygon({1, 2, 3}, {4, 5, 6}, 0)\n"
"gui.drawText(gui.FONT_UI, 0, 0, 'hi')"); "screen.drawText(screen.FONT_UI, 0, 0, 'hi')");
assert(bench.gui.degrees == 180); assert(bench.gui.degrees == 180);
assert(bench.gui.trace == "clear;fillPolygon3;drawText(hi);"); assert(bench.gui.trace == "clear;fillPolygon3;drawText(hi);");
expectError(state, "gui.fillPolygon({1, 2}, {3}, 0)"); expectError(state, "screen.fillPolygon({1, 2}, {3}, 0)");
expectError(state, "gui.color(300, 0, 0)"); expectError(state, "screen.color(300, 0, 0)");
run(state, "local response = http.get('https://example.test', {maxBytes = " run(state, "local response = http.get('https://example.test', {maxBytes = "
"16, headers = {Accept = 'text/plain'}})\n" "16, headers = {Accept = 'text/plain'}})\n"
@@ -89,6 +93,24 @@ int main() {
bench.http.headers[0].name == "Accept"); bench.http.headers[0].name == "Accept");
expectError(state, "http.get('https://example.test', {maxBytes = 999999})"); expectError(state, "http.get('https://example.test', {maxBytes = 999999})");
// cjson is required rather than global, because the library provides it and
// no firmware implements it. Decoding is lua-cjson's; what is asserted here
// is the wiring and the depth limit the panel's C stack needs.
run(state,
"local cjson = require 'cjson'\n"
"assert(rawget(_G, 'cjson') == nil)\n"
"local value = "
"cjson.decode('{\"a\":[1,2.5,true],\"b\":\"x\\\\u00e9\"}')\n"
"assert(math.type(value.a[1]) == 'integer' and value.a[2] == 2.5)\n"
"assert(value.a[3] == true and value.b == 'x\\u{e9}')\n"
"assert(cjson.decode('null') == cjson.null)\n"
"assert(cjson.encode({1, 2, 3}) == '[1,2,3]')\n"
"assert(not pcall(cjson.decode, '{'))\n"
"assert(not pcall(cjson.encode, print))\n"
"local deep = {}; for _ = 1, 40 do deep = {deep} end\n"
"assert(not pcall(cjson.encode, deep))\n"
"assert(not pcall(cjson.decode, string.rep('[', 40)))");
run(state, "assert(wifi.scan()[1].ssid == 'home')\n" run(state, "assert(wifi.scan()[1].ssid == 'home')\n"
"assert(wifi.isConnected())\n" "assert(wifi.isConnected())\n"
"assert(wifi.getLocalIP() == '192.168.1.5')\n" "assert(wifi.getLocalIP() == '192.168.1.5')\n"
@@ -99,10 +121,14 @@ int main() {
run(state, "assert(not ble.isInitialized())\n" run(state, "assert(not ble.isInitialized())\n"
"assert(ble.init())\n" "assert(ble.init())\n"
"assert(ble.isInitialized())\n" "assert(ble.isInitialized())\n"
"assert(ble.scan()[1].address == 'aa:bb')\n" "assert(ble.observe({ services = { '181A' } }))\n"
"assert(ble.isObserving())\n"
"assert(ble.observed()[1].address == 'aa:bb')\n"
"assert(ble.observed()[1].payload == 'payload')\n"
"assert(#ble.read('svc', 'chr') == 3)\n" "assert(#ble.read('svc', 'chr') == 3)\n"
"assert(ble.write('svc', 'chr', 'x\\0y'))"); "assert(ble.write('svc', 'chr', 'x\\0y'))");
assert(bench.ble.duration == 3000); assert(bench.ble.filter.services.size() == 1);
assert(bench.ble.filter.services[0] == "181A");
assert(bench.ble.value.size() == 3); assert(bench.ble.value.size() == 3);
run(state, "fired = 0\n" run(state, "fired = 0\n"
@@ -120,92 +146,120 @@ int main() {
expectError(state, "timer.after(0, function() end)"); expectError(state, "timer.after(0, function() end)");
run(state, run(state,
"assert(settings.setCalibration(100, 200, 300, 400))\n" "assert(touch.setCalibration(100, 200, 300, 400))\n"
"local x, y = input.getTouch()\n" "local x, y = touch.getPoint()\n"
"assert(x == 10 and y == 20)\n" "assert(x == 10 and y == 20)\n"
"assert(input.isTouched())\n" "assert(touch.isTouched())\n"
"assert(input.isPressed('confirm') and not input.isPressed('back'))\n" "assert(buttons.isPressed('confirm') and not buttons.isPressed('back'))\n"
"assert(#input.getButtons() == 4 and input.getButtons()[3] == " "assert(#buttons.getAll() == 4 and buttons.getAll()[3] == "
"'confirm')"); "'confirm')");
assert(bench.touch.calibration[3] == 400); assert(bench.touch.calibration[3] == 400);
// Chrome control and gradients are core: an e-ink provider flattens what it // Gradients are core: an e-ink provider flattens what it cannot show.
// cannot show. run(state, "screen.roundRect(0, 0, 10, 10, 4, 0, 0xFF, 0x00)\n"
run(state, "gui.setFullscreen(true)\n" "screen.roundRect(0, 0, 10, 10, 4, 0, nil, nil, 0xFF)");
"gui.roundRect(0, 0, 10, 10, 4, 0, 0xFF, 0x00)\n" assert(bench.gui.gradient);
"gui.roundRect(0, 0, 10, 10, 4, 0, nil, nil, 0xFF)");
assert(bench.gui.fullscreen && bench.gui.gradient);
assert(bench.gui.trace.find("roundRect(-,border);") != std::string::npos); assert(bench.gui.trace.find("roundRect(-,border);") != std::string::npos);
bench.gui.trace.clear(); bench.gui.trace.clear();
run(state, run(state,
"node.reset()\n" "tree.reset()\n"
"local root = node.create(nil, {type = 'box', w = 'fill', h = 'fill', " "local root = tree.create(nil, {type = 'box', w = 'fill', h = 'fill', "
"pad = 4, gap = 2})\n" "pad = 4, gap = 2})\n"
"local label = node.create(root, {type = 'text', label = 'Hello'})\n" "local label = tree.create(root, {type = 'text', label = 'Hello'})\n"
"local button = node.create(root, {type = 'button', h = 40, interactive " "local button = tree.create(root, {type = 'button', h = 40, interactive "
"= true})\n" "= true})\n"
"node.setStyle(root, {background = 0xFFFFFF, fill = 0xFFFFFF, color = 0, " "tree.setStyle(root, {background = 0xFFFFFF, fill = 0xFFFFFF, color = 0, "
"face = 0xEEEEEE,\n" "face = 0xEEEEEE,\n"
" border = 0x333333, focusColor = 0xFF0000})\n" " border = 0x333333, focusColor = 0xFF0000})\n"
"assert(node.layout(root, 0, 0, 320, 240))\n" "assert(tree.layout(root, 0, 0, 320, 240))\n"
"local x, y, w, h = node.getRect(label)\n" "local x, y, w, h = tree.getRect(label)\n"
"assert(x == 4 and y == 4 and w == 312 and h == 16)\n" "assert(x == 4 and y == 4 and w == 312 and h == 16)\n"
"assert(node.getLabel(label) == 'Hello')\n" "assert(tree.getLabel(label) == 'Hello')\n"
"assert(node.hit(root, 10, 40) == button)\n" "assert(tree.hit(root, 10, 40) == button)\n"
"assert(node.hit(root, 10, 200) == nil)\n" // Hit is geometry: a point inside root but over no child answers root, not nil.
"assert(node.focusFirst(root) == button)\n" "assert(tree.hit(root, 10, 200) == root)\n"
"assert(node.getFocus() == button)\n" "assert(tree.focusFirst(root) == button)\n"
"assert(node.moveFocus(root, 'up') == button)\n" "assert(tree.getFocus() == button)\n"
"node.setPressed(button, true)\n" "assert(tree.moveFocus(root, 'up') == button)\n"
"assert(node.isPressed(button))\n" "tree.setPressed(button, true)\n"
"assert(node.getCount() == 3 and node.getFootprint() > 0)\n" "assert(tree.isPressed(button))\n"
"node.draw(root)\n" "assert(tree.getCount() == 3 and tree.getFootprint() > 0)\n"
"node.dropScratch()"); "tree.draw(root)\n"
"tree.dropScratch()");
assert(bench.gui.trace.find("drawText(Hello);") != std::string::npos); assert(bench.gui.trace.find("drawText(Hello);") != std::string::npos);
// The bordered box rounds its corners; the button fills without one. // The bordered box rounds its corners; the button fills without one.
assert(bench.gui.trace.find("roundRect(fill,border);") != std::string::npos); assert(bench.gui.trace.find("roundRect(fill,border);") != std::string::npos);
assert(bench.gui.trace.find("roundRect(fill,-);") != std::string::npos); assert(bench.gui.trace.find("roundRect(fill,-);") != std::string::npos);
expectError(state, "node.create(nil, {type = 'nope'})"); expectError(state, "tree.create(nil, {type = 'nope'})");
run(state, run(state,
"node.reset()\n" "tree.reset()\n"
"local root = node.create(nil, {type = 'custom', w = 'fill', h = " "local root = tree.create(nil, {type = 'custom', w = 'fill', h = "
"'fill'})\n" "'fill'})\n"
"painted = 0\n" "painted = 0\n"
"node.setPainter(function(id, x, y, w, h) painted = painted + w end)\n" "tree.setPainter(function(id, x, y, w, h) painted = painted + w end)\n"
"assert(node.layout(root, 0, 0, 320, 240))\n" "assert(tree.layout(root, 0, 0, 320, 240))\n"
"node.draw(root)\n" "tree.draw(root)\n"
"assert(painted == 320)"); "assert(painted == 320)");
// Callbacks: only init failing stops an app, and a release fires the tap // A scrolling box: children measure past its edges, panning moves them and is
// alias after the up. // clamped to the content, and what is scrolled out of the box is neither hit
// nor painted outside it.
bench.gui.trace.clear();
run(state, run(state,
"events = {}\n" "tree.reset()\n"
"local function note(name) return function(a) events[#events + 1] = name " "local list = tree.create(nil, {type = 'box', w = 'fill', h = 'fill',\n"
".. ':' .. tostring(a) end end\n" " scrollX = true, scrollY = true})\n"
"function init(arg) events[#events + 1] = 'init:' .. tostring(arg) end\n" "local rows = {}\n"
"function draw(delta) events[#events + 1] = 'draw:' .. delta end\n" "for i = 1, 5 do rows[i] = tree.create(list, {type = 'box', w = 400, h = "
"on_touch_down = note('down')\n" "100, interactive = true}) end\n"
"on_touch_up = note('up')\n" "tree.setStyle(list, {background = 0xFFFFFF, fill = 0xFFFFFF, border = "
"on_touch = note('tap')\n" "0x333333})\n"
"on_button_up = note('bup')\n" // The box is the panel; the content is deliberately larger on both axes.
"on_button = note('btap')"); "assert(tree.layout(list, 0, 0, 320, 240))\n"
bench.gui.commits = 0; "local maxX, maxY = tree.getScrollRange(list)\n"
assert(runtime.callInit("book.epub")); "assert(maxX == 400 - 320, 'content is wider than the box')\n"
runtime.callDraw(33); "assert(maxY == 5 * 100 - 240, 'content is taller than the box')\n"
runtime.callTouch(esp32lua::TouchPhase::Down, 5, 6); // Panning shifts the children, and getRect keeps answering screen space.
runtime.callTouch(esp32lua::TouchPhase::Move, 5, "tree.setScroll(list, 30, 150)\n"
7); // the app defines no on_touch_move "local x, y = tree.getRect(rows[1])\n"
runtime.callTouch(esp32lua::TouchPhase::Up, 5, 8); "assert(x == -30 and y == -150, 'row moved by the scroll')\n"
runtime.callButton("confirm", false); "local sx, sy = tree.getScroll(list)\n"
run(state, "assert(sx == 30 and sy == 150)\n"
"assert(table.concat(events, ' ') == " // The box itself does not move, only what it holds.
"'init:book.epub draw:33 down:5 up:5 tap:5 bup:confirm btap:confirm')"); "local bx, by = tree.getRect(list)\n"
// One commit per visit to the app, so six calls and not seven: the release "assert(bx == 0 and by == 0)\n"
// and its tap alias are one visible change, and the move nobody handled still // Row 1 is scrolled above the box, so a tap at the top hits row 2.
// ends a batch. "assert(tree.hit(list, 10, 10) == rows[2], 'scrolled-out row is not hit')\n"
assert(bench.gui.commits == 6); // Hit is pure geometry: the deepest node covering the point, else the container. A
// scroll pan finds its box by bubbling up from here, not by a flag on hit.
"local bare = tree.create(nil, {type = 'box', w = 'fill', h = 'fill', scrollY = true})\n"
"local child = tree.create(bare, {type = 'box', w = 100, h = 900})\n"
"assert(tree.layout(bare, 0, 0, 320, 240))\n"
"assert(tree.hit(bare, 10, 10) == child, 'deepest node by geometry wins')\n"
"assert(tree.hit(bare, 200, 10) == bare, 'the container answers where no child covers it')\n"
"tree.dropScratch()\n"
// Clamped, never past the content's far edge or before its start.
"tree.setScroll(list, 9999, 9999)\n"
"local cx, cy = tree.getScroll(list)\n"
"assert(cx == maxX and cy == maxY, 'clamped to the content')\n"
"tree.setScroll(list, -50, -50)\n"
"local zx, zy = tree.getScroll(list)\n"
"assert(zx == 0 and zy == 0, 'clamped at the origin')\n"
"tree.draw(list)\n"
"tree.dropScratch()");
// The subtree is scissored to the box, so a row hanging past it cannot paint
// over whatever sits outside.
assert(bench.gui.trace.find("clip(0,0,320,240);") != std::string::npos);
assert(bench.gui.trace.find("unclip;") != std::string::npos);
// A scrolled axis has no size to hand down, so the box needs one of its own.
expectError(state, "tree.reset()\n"
"local l = tree.create(nil, {type = 'box', scrollY = "
"true})\n"
"tree.create(l, {type = 'box', w = 10, h = 10})\n"
"assert(tree.layout(l, 0, 0, 320, 240))");
// A timer firing inside draw is still one batch. // A timer firing inside draw is still one batch.
run(state, "function draw() timer.after(1, function() end) end\n" run(state, "function draw() timer.after(1, function() end) end\n"
@@ -214,12 +268,64 @@ int main() {
runtime.callTimer(bench.timer.scheduled.back()); runtime.callTimer(bench.timer.scheduled.back());
assert(bench.gui.commits == 1); assert(bench.gui.commits == 1);
run(state, "function init() error('boom') end"); // Callbacks land on the table main.lua returns, only start() failing stops an
assert(!runtime.callInit("")); // app, and a release fires the tap alias after the up.
assert(bench.log.message.find("init: ") == 0); {
run(state, "function draw() error('kaboom') end"); fake::Bench chrome;
runtime.callDraw(1); // a failed frame logs and the app keeps running chrome.fs.files["/.lua/main.lua"] =
assert(bench.log.message.find("draw: ") == 0); "events = {}\n"
"local function note(name) return function(a) events[#events + 1] = "
"name .. ':' .. tostring(a) end end\n"
"return {\n"
" start = function(args) events[#events + 1] = 'start:' .. "
"args.app .. ':' .. args.arg end,\n"
" draw = function(delta) if delta < 0 then error('kaboom') end\n"
" events[#events + 1] = 'draw:' .. delta end,\n"
" onTouchDown = note('down'),\n"
" onTouchUp = note('up'),\n"
" onTouch = note('tap'),\n"
" onButtonUp = note('bup'),\n"
" onButton = note('btap'),\n"
"}\n";
esp32lua::Runtime hosted(chrome.providers());
assert(hosted.startApp(esp32lua::MAIN_PATH,
"{\"app\":\"Reader\",\"arg\":\"book.epub\"}"));
chrome.gui.commits = 0;
hosted.callDraw(33);
hosted.callTouch(esp32lua::TouchPhase::Down, 5, 6);
hosted.callTouch(esp32lua::TouchPhase::Move, 5,
7); // main.lua defines no onTouchMove
hosted.callTouch(esp32lua::TouchPhase::Up, 5, 8);
hosted.callButton("confirm", false);
run(hosted.state(),
"assert(table.concat(events, ' ') == "
"'start:Reader:book.epub draw:33 down:5 up:5 tap:5 bup:confirm "
"btap:confirm')");
// One commit per visit, so five calls and not six: the release and its tap
// alias are one visible change, and the move nobody handled still ends a
// batch.
assert(chrome.gui.commits == 5);
hosted.callDraw(-1); // a failed frame logs and the app keeps running
assert(chrome.log.message.find("draw: ") == 0);
assert(hosted.hasApp());
chrome.fs.files["/.lua/main.lua"] =
"return { start = function() error('boom') end }";
assert(!hosted.startApp(esp32lua::MAIN_PATH));
assert(chrome.log.message.find("start: ") == 0);
assert(!hosted.hasApp());
// Arguments that are not JSON leave no app running rather than a state with
// no start() behind it.
chrome.fs.files["/.lua/main.lua"] = "return { start = function() end }";
assert(!hosted.startApp(esp32lua::MAIN_PATH, "{not json"));
assert(!hosted.hasApp());
chrome.fs.files["/.lua/main.lua"] = "return 7";
assert(!hosted.startApp(esp32lua::MAIN_PATH));
assert(chrome.log.message == "/.lua/main.lua returned no table");
}
// A feature callback without its provider is a wiring bug, not a silent // A feature callback without its provider is a wiring bug, not a silent
// no-op. // no-op.
@@ -231,66 +337,92 @@ int main() {
assert(noTouch.open()); assert(noTouch.open());
noTouch.callTouch(esp32lua::TouchPhase::Down, 1, 1); noTouch.callTouch(esp32lua::TouchPhase::Down, 1, 1);
assert(headless.log.message == "callTouch without a touch provider"); assert(headless.log.message == "callTouch without a touch provider");
run(noTouch.state(), run(noTouch.state(), "assert(touch == nil and buttons.isPressed ~= nil)");
"assert(input.getTouch == nil and input.isPressed ~= nil)");
} }
// App loading: a fresh state per app, require reaching the app directory and // A screenless firmware still runs: no gui provider, no screen/tree
// /.lua/lib, and navigation applied between batches rather than inside a // namespaces, but a callback batch still completes.
{
fake::Bench screenless;
esp32lua::Providers providers = screenless.providers();
providers.gui = nullptr;
esp32lua::Runtime noScreen(providers);
assert(noScreen.open());
assert(!noScreen.hasFeature("screen"));
run(noScreen.state(), "assert(not sys.hasFeature('screen'))\n"
"assert(screen == nil and tree == nil)\n"
"assert(sys.getTimezone() == 'UTC0')");
noScreen.callDraw(0);
}
// App loading: a fresh state per app, main.lua deciding where apps and
// modules live, and navigation applied between batches rather than inside a
// callback. // callback.
{ {
fake::Bench host; fake::Bench host;
// The tree is main.lua's, so the test states it the way a card would.
// Routing, history and the launcher are all this file's, built out of the
// arguments it is handed; the runtime knows only the path it loads.
host.fs.files["/.lua/main.lua"] =
"package.path = '/.lua/lib/?.lua'\n"
"return {\n"
" start = function(args)\n"
" args = args or {app = 'Home'}\n"
" route, history = args.app, args.history or {}\n"
" local dir = '/.lua/apps/' .. route\n"
" package.path = dir .. '/?.lua;/.lua/lib/?.lua'\n"
" app = assert(loadfile(dir .. '/main.lua'))()\n"
" app.init(args.arg)\n"
" end,\n"
"}\n";
host.fs.files["/.lua/lib/greet.lua"] = host.fs.files["/.lua/lib/greet.lua"] =
"return {hello = function() return 'hi' end}"; "return {hello = function() return 'hi' end}";
host.fs.files["/.lua/apps/Home/main.lua"] = host.fs.files["/.lua/apps/Home/main.lua"] =
"local greet = require('greet')\n" "local greet = require('greet')\n"
"function init(arg) started = greet.hello() .. ':' .. tostring(arg) " "return {init = function(arg) started = greet.hello() .. ':' .. "
"end"; "tostring(arg) end}";
host.fs.files["/.lua/apps/Reader/main.lua"] = host.fs.files["/.lua/apps/Reader/main.lua"] =
"local page = require('page')\n" "local page = require('page')\n"
"function init(arg) started = page.name .. ':' .. arg end"; "return {init = function(arg) started = page.name .. ':' .. arg end}";
host.fs.files["/.lua/apps/Reader/page.lua"] = "return {name = 'page'}"; host.fs.files["/.lua/apps/Reader/page.lua"] = "return {name = 'page'}";
host.fs.files["/.lua/apps/Reader/Notes/main.lua"] = "function init() end"; host.fs.files["/.lua/apps/Reader/Notes/main.lua"] =
"return {init = function() end}";
esp32lua::Runtime app(host.providers()); esp32lua::Runtime app(host.providers());
assert(app.startApp("Home")); assert(app.startApp(esp32lua::MAIN_PATH));
assert(app.appId() == "Home" && app.appTitle() == "Home"); assert(app.appPath() == esp32lua::MAIN_PATH);
assert(app.appDataPath() == "/.lua/data/Home"); run(app.state(), "assert(started == 'hi:nil' and route == 'Home')");
run(app.state(), "assert(started == 'hi:')");
// A subapp shares the app ID, so both routes share one data directory. // The arguments cross the teardown as JSON, so a nested table survives and
run(app.state(), "sys.launch('Reader', 'book.epub')"); // the history is whatever main.lua chose to put in it.
run(app.state(),
"sys.startApp('/.lua/main.lua', "
"{app = 'Reader', arg = 'book.epub', history = {'Home'}})");
assert(app.hasPendingNavigation()); assert(app.hasPendingNavigation());
run(app.state(), "assert(started == 'hi:')"); // the current app keeps run(app.state(), "assert(started == 'hi:nil')"); // still running until
// running until applied // applied
assert(app.applyPendingNavigation()); assert(app.applyPendingNavigation());
run(app.state(), "assert(started == 'page:book.epub')"); run(app.state(), "assert(started == 'page:book.epub')\n"
run(app.state(), "sys.launch('Reader/Notes')"); "assert(route == 'Reader' and history[1] == 'Home')");
// Going back is the same call with the stack main.lua kept, popped by
// main.lua: nothing in C++ remembers where the app came from.
run(app.state(), "sys.startApp('/.lua/main.lua', {app = history[1]})");
assert(app.applyPendingNavigation()); assert(app.applyPendingNavigation());
assert(app.appPath() == "Reader/Notes" && app.appId() == "Reader"); run(app.state(), "assert(route == 'Home' and #history == 0)");
assert(app.appDataPath() == "/.lua/data/Reader");
// Back unwinds history, then lands on the launcher. // Arguments JSON cannot carry raise at the call, leaving the app running.
run(app.state(), "sys.back()"); expectError(app.state(), "sys.startApp('/.lua/main.lua', {f = print})");
assert(app.applyPendingNavigation() && app.appPath() == "Reader"); assert(!app.hasPendingNavigation());
run(app.state(), "sys.back()"); run(app.state(), "assert(route == 'Home')");
assert(app.applyPendingNavigation() && app.appPath() == "Home"); expectError(app.state(), "sys.startApp('/.lua/main.lua', 'not a table')");
run(app.state(), "sys.back()");
assert(app.applyPendingNavigation() && app.appPath() == "Home");
// sys.replace does not grow history, so back from it still reaches the // A missing file, a broken app, and a traversal all leave nothing running.
// launcher. assert(!app.startApp("/.lua/absent.lua"));
run(app.state(), "sys.replace('Reader', 'other.epub')");
assert(app.applyPendingNavigation() && app.appPath() == "Reader");
run(app.state(), "sys.back()");
assert(app.applyPendingNavigation() && app.appPath() == "Home");
// A missing app, a broken app, and a traversal all leave nothing running.
assert(!app.startApp("Absent"));
assert(!app.hasApp() && app.state() == nullptr); assert(!app.hasApp() && app.state() == nullptr);
host.fs.files["/.lua/apps/Broken/main.lua"] = host.fs.files["/.lua/apps/Broken/main.lua"] =
"function init() error('nope') end"; "return {init = function() error('nope') end}";
assert(!app.startApp("Broken")); assert(!app.startApp(esp32lua::MAIN_PATH, "{\"app\":\"Broken\"}"));
assert(!app.hasApp()); assert(!app.hasApp());
assert(!app.startApp("../secrets")); assert(!app.startApp("../secrets"));
assert(host.log.message.find("refusing to start") == 0); assert(host.log.message.find("refusing to start") == 0);
+8 -4
View File
@@ -35,7 +35,9 @@ def main():
lib_dir = pathlib.Path(lib_dir) lib_dir = pathlib.Path(lib_dir)
out_path = pathlib.Path(out_path) out_path = pathlib.Path(out_path)
sources = sorted(lib_dir.glob("*.lua")) # Recurse so ui/keyboard.lua embeds as the module require("ui.keyboard") asks for: the
# module name is the path with dots, the C array identifier the same with underscores.
sources = sorted(lib_dir.rglob("*.lua"))
arrays = [] arrays = []
rows = [] rows = []
for src in sources: for src in sources:
@@ -45,10 +47,12 @@ def main():
subprocess.run([luac32, str(src), tmp_path], check=True) subprocess.run([luac32, str(src), tmp_path], check=True)
bytecode = pathlib.Path(tmp_path).read_bytes() bytecode = pathlib.Path(tmp_path).read_bytes()
pathlib.Path(tmp_path).unlink() pathlib.Path(tmp_path).unlink()
name = src.stem parts = src.relative_to(lib_dir).with_suffix("").parts
name = ".".join(parts)
ident = "_".join(parts)
comma = ", ".join(f"0x{b:02x}" for b in bytecode) comma = ", ".join(f"0x{b:02x}" for b in bytecode)
arrays.append(f"static const unsigned char {name}[] = {{{comma}}};\n") arrays.append(f"static const unsigned char {ident}[] = {{{comma}}};\n")
rows.append(f' {{"{name}", {name}, sizeof({name})}},') rows.append(f' {{"{name}", {ident}, sizeof({ident})}},')
footer = "const EmbeddedModule embedded_modules[] = {{\n{rows}\n}};\nconst size_t embedded_modules_count = {count};\n" footer = "const EmbeddedModule embedded_modules[] = {{\n{rows}\n}};\nconst size_t embedded_modules_count = {count};\n"
generated = HEADER + "".join(arrays) + footer.format(rows="\n".join(rows), count=len(sources)) + FOOTER generated = HEADER + "".join(arrays) + footer.format(rows="\n".join(rows), count=len(sources)) + FOOTER
+56 -15
View File
@@ -7,13 +7,16 @@ table that registers them:
// @lua-preamble ---@alias Feature "touch"|"lcd" // @lua-preamble ---@alias Feature "touch"|"lcd"
// @lua-module sys SysLib creates the global // @lua-module sys SysLib creates the global
// @lua-augment gui GuiLib extends a global another file created // @lua-augment gui GuiLib extends a global another file created
// @lua-global core/runtime app callbacks, written to an explicit path // @lua-app App a class of fields on the table an app returns
// @lua-const MAX_READ_BYTES integer 65536 Largest portable read. // @lua-const MAX_READ_BYTES integer 65536 Largest portable read.
// @lua-field home? string a plain field of a @lua-app class
// @lua-postamble -- notes rendered after the module // @lua-postamble -- notes rendered after the module
Per function, `// --- text`, `// @param name type desc`, and `// @return type desc`. Functions come Per function, `// --- text`, `// @param name type desc`, and `// @return type desc`. Functions come
from the luaL_Reg entries below the directive, or from `// @lua-fn name` for the app callbacks the from the luaL_Reg entries below the directive, or from `// @lua-fn name` for the callbacks the
runtime calls rather than registers. runtime calls on an app's table rather than registers. A `@lua-fn` name ending in `?` is one the
app may leave out; the class it lands in is what an app composes, so a device without the feature
contributes no fields rather than the runtime hiding some.
""" """
import argparse import argparse
@@ -25,9 +28,10 @@ SOURCES = [ROOT / "native/src/bindings", ROOT / "native/src/runtime"]
OUTPUT = ROOT / "lua/api" OUTPUT = ROOT / "lua/api"
MODULE = re.compile(r"^// @lua-(?P<kind>module|augment) (?P<name>\w+) (?P<class_>\w+)$") MODULE = re.compile(r"^// @lua-(?P<kind>module|augment) (?P<name>\w+) (?P<class_>\w+)$")
GLOBAL = re.compile(r"^// @lua-global(?: (?P<path>\S+))?$") APP = re.compile(r"^// @lua-app (?P<class_>\w+)(?: (?P<path>\S+))?$")
FUNCTION = re.compile(r"^// @lua-fn (?P<name>\w+)$") FUNCTION = re.compile(r"^// @lua-fn (?P<name>\w+\??)$")
CONST = re.compile(r"^// @lua-const (?P<name>\w+) (?P<type>\S+) (?P<value>\S+)(?: (?P<desc>.*))?$") CONST = re.compile(r"^// @lua-const (?P<name>\w+) (?P<type>\S+) (?P<value>\S+)(?: (?P<desc>.*))?$")
FIELD = re.compile(r"^// @lua-field (?P<name>\w+\??) (?P<type>\S+)(?: (?P<desc>.*))?$")
FIX = re.compile(r"^// @lua-(?P<where>preamble|postamble) ?(?P<text>.*)$") FIX = re.compile(r"^// @lua-(?P<where>preamble|postamble) ?(?P<text>.*)$")
TABLE = re.compile(r"^\s*const luaL_Reg (?P<var>\w+)\[\] = \{$") TABLE = re.compile(r"^\s*const luaL_Reg (?P<var>\w+)\[\] = \{$")
ENTRY = re.compile(r'^\s*\{"(?P<name>\w+)",\s*\w+\},?$') ENTRY = re.compile(r'^\s*\{"(?P<name>\w+)",\s*\w+\},?$')
@@ -53,6 +57,7 @@ def new_module(kind, name, class_name, path=None):
"class": class_name, "class": class_name,
"path": path, "path": path,
"consts": [], "consts": [],
"fields": [],
"preamble": [], "preamble": [],
"postamble": [], "postamble": [],
"functions": [], "functions": [],
@@ -92,18 +97,19 @@ def parse(path):
pending_module = new_module(module.group("kind"), module.group("name"), module.group("class_")) pending_module = new_module(module.group("kind"), module.group("name"), module.group("class_"))
modules.append(pending_module) modules.append(pending_module)
continue continue
glob = GLOBAL.match(stripped) app = APP.match(stripped)
if glob: if app:
# Callbacks are globals an app defines, so they need no table and no class. # Callbacks are fields of the table an app returns, so the block is a class
pending_module = new_module("global", None, None, glob.group("path")) # with no table of its own to register.
pending_module = new_module("app", None, app.group("class_"), app.group("path"))
modules.append(pending_module) modules.append(pending_module)
current, in_table = pending_module, False current, in_table = pending_module, False
doc = reset() doc = reset()
continue continue
function = FUNCTION.match(stripped) function = FUNCTION.match(stripped)
if function: if function:
if not current or current["kind"] != "global": if not current or current["kind"] != "app":
raise SystemExit(f"{where}: @lua-fn outside a @lua-global block") raise SystemExit(f"{where}: @lua-fn outside a @lua-app block")
if not doc["doc"]: if not doc["doc"]:
raise SystemExit(f"{where}: {function.group('name')} has no description") raise SystemExit(f"{where}: {function.group('name')} has no description")
current["functions"].append((function.group("name"), doc)) current["functions"].append((function.group("name"), doc))
@@ -120,6 +126,15 @@ def parse(path):
) )
continuation = (target["consts"], len(target["consts"]) - 1, 3) continuation = (target["consts"], len(target["consts"]) - 1, 3)
continue continue
field = FIELD.match(stripped)
if field:
if not target or target["kind"] != "app":
raise SystemExit(f"{where}: @lua-field outside a @lua-app block")
target["fields"].append(
(field.group("name"), lua_type(field.group("type")), field.group("desc") or "")
)
continuation = (target["fields"], len(target["fields"]) - 1, 2)
continue
fix = FIX.match(stripped) fix = FIX.match(stripped)
if fix: if fix:
if not target: if not target:
@@ -135,8 +150,8 @@ def parse(path):
raise SystemExit(f"{where}: {table.group('var')} has no @lua-module or @lua-augment") raise SystemExit(f"{where}: {table.group('var')} has no @lua-module or @lua-augment")
current, pending_module, in_table, doc = pending_module, None, True, reset() current, pending_module, in_table, doc = pending_module, None, True, reset()
continue continue
# A @lua-global block documents callbacks the runtime calls, so it has no table to sit in. # A @lua-app block documents callbacks the runtime calls, so it has no table to sit in.
if not in_table and not (current and current["kind"] == "global"): if not in_table and not (current and current["kind"] == "app"):
continue continue
if stripped == "};": if stripped == "};":
in_table = False in_table = False
@@ -169,13 +184,28 @@ def parse(path):
doc = reset() doc = reset()
for module in modules: for module in modules:
if module["kind"] == "global": if module["kind"] == "app":
continue continue
if not module["functions"]: if not module["functions"]:
raise SystemExit(f"{path.relative_to(ROOT)}: {module['name']} registers no annotated functions") raise SystemExit(f"{path.relative_to(ROOT)}: {module['name']} registers no annotated functions")
return modules return modules
def signature(doc):
"""The `fun(...)` type of one callback, for the class an app composes."""
params = ", ".join(
f"{param}{'?' if type_.endswith('?') else ''}: {type_.rstrip('?')}" for param, type_, _ in doc["params"]
)
returns = ", ".join(type_ for type_, _ in doc["returns"])
return f"fun({params})" + (f": {returns}" if returns else "")
def describe(doc):
"""A field carries one line, so a wrapped description joins back into a sentence."""
text = " ".join(doc["doc"])
return f" {text}" if text else ""
def render(source, modules): def render(source, modules):
lines = ["---@meta", "", f"-- Generated from {source.relative_to(ROOT)}. Do not edit.", ""] lines = ["---@meta", "", f"-- Generated from {source.relative_to(ROOT)}. Do not edit.", ""]
for module in modules: for module in modules:
@@ -184,7 +214,18 @@ def render(source, modules):
lines.extend(module["preamble"]) lines.extend(module["preamble"])
if module["preamble"]: if module["preamble"]:
lines.append("") lines.append("")
if module["kind"] != "global": if module["kind"] == "app":
lines.append(f"---@class {module['class']}")
for field, type_, desc in module["fields"]:
lines.append(f"---@field {field} {type_}{(' ' + desc) if desc else ''}")
for function, doc in module["functions"]:
lines.append(f"---@field {function} {signature(doc)}{describe(doc)}")
lines.append("")
if module["postamble"]:
lines.extend(module["postamble"] + [""])
continue
if module["kind"] != "app":
lines.append(f"---@class {module['class']}") lines.append(f"---@class {module['class']}")
for const, type_, _, desc in module["consts"]: for const, type_, _, desc in module["consts"]:
lines.append(f"---@field {const} {type_}{(' ' + desc) if desc else ''}") lines.append(f"---@field {const} {type_}{(' ' + desc) if desc else ''}")
+25 -1
View File
@@ -3,7 +3,7 @@
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from gen_api import ROOT, parse from gen_api import ROOT, parse, render
with tempfile.TemporaryDirectory(dir=ROOT) as directory: with tempfile.TemporaryDirectory(dir=ROOT) as directory:
@@ -30,4 +30,28 @@ doc = module["functions"][0][1]
assert doc["doc"] == ["Reads one complete file."] assert doc["doc"] == ["Reads one complete file."]
assert doc["params"][0][2] == "Absolute file path." assert doc["params"][0][2] == "Absolute file path."
assert doc["returns"][0][1] == "File contents." assert doc["returns"][0][1] == "File contents."
# Callbacks render as fields of a class an app composes, never as global functions.
with tempfile.TemporaryDirectory(dir=ROOT) as directory:
source = Path(directory) / "handlers.cpp"
source.write_text(
"""// @lua-app TouchHandlers
// @lua-field home? string The route back lands on.
// ---Fired when the finger lands.
// @param x integer
// @param y integer
// @lua-fn onTouchDown?
// ---Mounts the route.
// @param route string
// @param arg string|nil
// @lua-fn start
"""
)
output = render(source, parse(source))
assert "---@class TouchHandlers" in output
assert "---@field home? string The route back lands on." in output
assert "---@field onTouchDown? fun(x: integer, y: integer) Fired when the finger lands." in output
assert "---@field start fun(route: string, arg?: string) Mounts the route." in output
assert "function " not in output
print("ok") print("ok")