Compare commits
27 Commits
main
...
app-tables
| Author | SHA1 | Date | |
|---|---|---|---|
| 277fd11fe6 | |||
| cf1831bf93 | |||
| ddcdb52bb2 | |||
| eea6366ea2 | |||
| 94ff3c5ca5 | |||
| 4568f56f92 | |||
| 5d44234046 | |||
| 8ceca622b9 | |||
| 2104a91fc3 | |||
| 5cec396697 | |||
| 873e003c45 | |||
| a09bcfaf06 | |||
| eac4084f80 | |||
| 94d298dd45 | |||
| 2046938d32 | |||
| e5ed980294 | |||
| 26f3fdb6e6 | |||
| 772618ef89 | |||
| b9f7c9347c | |||
| 75b3a2c490 | |||
| 445a9b2b8f | |||
| 4b89b27947 | |||
| 25b576a3d9 | |||
| 2fc3abc487 | |||
| 3f26c4ff10 | |||
| e3153f57c5 | |||
| e85adfa757 |
@@ -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. `ble.observe(filter)` returns a reusable cursor iterator that retrieves one buffered advert at a time; runtime teardown ends the observation.
|
||||||
|
|||||||
-174
@@ -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.
|
|
||||||
@@ -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 $@
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-8
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
-- Generated from native/src/bindings/core/ble.cpp. Do not edit.
|
-- Generated from native/src/bindings/core/ble.cpp. Do not edit.
|
||||||
|
|
||||||
---@class BleDevice
|
---@alias BleObservationIterator fun(state: nil, after: string|nil): address: string|nil, name: string|nil, rssi: integer|nil, payload: string|nil, lastSeenMs: integer|nil
|
||||||
---@field name string
|
|
||||||
---@field address string
|
---@class BleFilter
|
||||||
---@field rssi integer
|
---@field services? string[] Service-data UUIDs to keep.
|
||||||
|
---@field manufacturers? integer[] Manufacturer ids to keep.
|
||||||
|
|
||||||
---@class BleLib
|
---@class BleLib
|
||||||
ble = {}
|
ble = {}
|
||||||
@@ -23,11 +24,18 @@ 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. The returned iterator is reusable; observation stops with its Lua state.
|
||||||
---@param durationMs? integer Defaults to 3000.
|
---@param filter? BleFilter Keep only matching adverts; nil keeps all.
|
||||||
---@return BleDevice[]? devices
|
---@return BleObservationIterator? observations
|
||||||
---@return string? error
|
---@return string? error
|
||||||
function ble.scan(durationMs) end
|
function ble.observe(filter) end
|
||||||
|
|
||||||
|
---Stops observing and clears the buffered devices.
|
||||||
|
function ble.unobserve() end
|
||||||
|
|
||||||
|
---Whether advertisement observation is running.
|
||||||
|
---@return boolean
|
||||||
|
function ble.isObserving() end
|
||||||
|
|
||||||
---Connects to a peripheral.
|
---Connects to a peripheral.
|
||||||
---@param address string
|
---@param address string
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -15,6 +15,36 @@
|
|||||||
---@field expectedSize? integer Exact expected byte count.
|
---@field expectedSize? integer Exact expected byte count.
|
||||||
---@field sha256? string Exact expected SHA-256 as 64 hexadecimal characters.
|
---@field sha256? string Exact expected SHA-256 as 64 hexadecimal characters.
|
||||||
|
|
||||||
|
---@class HttpServer
|
||||||
|
---@field root? string Directory of static files, answered only where onRequest declined, so an app can override any file in it.
|
||||||
|
---@field port? integer Defaults to 80.
|
||||||
|
---@field hostname? string Advertised over mDNS as <hostname>.local; omitted advertises nothing.
|
||||||
|
---@field headers? string[] Request headers to collect, named up front because the parser keeps no others. Authorization is always kept.
|
||||||
|
---@field onRequest? fun(req: HttpServerRequest): HttpExchange|nil
|
||||||
|
|
||||||
|
---@class HttpServerRequest
|
||||||
|
---@field method string
|
||||||
|
---@field path string Path only, without the query string.
|
||||||
|
---@field headers table<string, string> Lowercased names.
|
||||||
|
---@field args table<string, string> Query parameters, plus an urlencoded form's fields; empty once a body streams, so an upload names what it is in the path or a header.
|
||||||
|
---@field length integer Content-Length; zero when chunked or absent.
|
||||||
|
---@field filename string|nil Multipart's original name, flattened to a basename.
|
||||||
|
|
||||||
|
---@class HttpReply
|
||||||
|
---@field status? integer Defaults to 200.
|
||||||
|
---@field headers? table<string, string> Content-Type lives here; there is no second way to set it.
|
||||||
|
---@field body? string Response from memory.
|
||||||
|
---@field streamFrom? string Response sent off the card; exclusive with body.
|
||||||
|
|
||||||
|
---@class HttpExchange : HttpReply
|
||||||
|
---@field streamTo? string Request body written to this path, in chunks; omitted drains it unread.
|
||||||
|
---@field done? fun(result: HttpResult): HttpReply|nil Reply computed once the body has landed; exclusive with the reply fields.
|
||||||
|
|
||||||
|
---@class HttpResult
|
||||||
|
---@field ok boolean False when the write failed.
|
||||||
|
---@field bytes integer
|
||||||
|
---@field path string
|
||||||
|
|
||||||
---@class HttpLib
|
---@class HttpLib
|
||||||
---@field MAX_RESPONSE_BYTES integer Largest portable in-memory response body.
|
---@field MAX_RESPONSE_BYTES integer Largest portable in-memory response body.
|
||||||
http = {}
|
http = {}
|
||||||
@@ -65,6 +95,15 @@ function http.patch(url, body, options) end
|
|||||||
---@return string? error
|
---@return string? error
|
||||||
function http.download(url, destination, options) end
|
function http.download(url, destination, options) end
|
||||||
|
|
||||||
|
---Serves one connection at a time until stopServing(), or until the app's state closes. Request bodies stream to the card and responses stream off it, so neither passes through the heap. Answering runs on the Lua task, so a large upload holds the frame.
|
||||||
|
---@param options HttpServer
|
||||||
|
---@return boolean? ok
|
||||||
|
---@return string? error Wifi is down, or the port is taken.
|
||||||
|
function http.serve(options) end
|
||||||
|
|
||||||
|
---Closes the listener and drops the handler.
|
||||||
|
function http.stopServing() end
|
||||||
|
|
||||||
---Percent-encodes a string for use in a URL.
|
---Percent-encodes a string for use in a URL.
|
||||||
---@param input string
|
---@param input string
|
||||||
---@return string
|
---@return string
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+340
-154
@@ -12,10 +12,8 @@ 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 fit? integer Maximum label width.
|
---@field fit? integer Maximum label width.
|
||||||
---@field background? GuiColor
|
---@field background? GuiColor
|
||||||
---@field face? GuiColor
|
---@field face? GuiColor
|
||||||
@@ -27,8 +25,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 +42,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 },
|
||||||
@@ -51,13 +50,35 @@ local THEMES = {
|
|||||||
|
|
||||||
local enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {}
|
local enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {}
|
||||||
-- A widget that paints its own press feedback opts out, so pressing one key does not
|
-- A widget that paints its own press feedback opts out, so pressing one key does not
|
||||||
-- repaint the whole node the way a pressed style would.
|
-- repaint the whole node the way a pressed style would. Only the opt-outs are stored:
|
||||||
local pressStyles = {}
|
-- keyed the other way this held one entry per node, which on a full screen is a hash
|
||||||
|
-- part of several kB recording that almost nothing is an exception.
|
||||||
|
local noPressStyle = {}
|
||||||
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 +88,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,87 +134,76 @@ 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 = {}
|
noPressStyle = {}
|
||||||
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)
|
||||||
|
|
||||||
local STYLE_KEYS = {
|
-- Shared by every builder called without a spec. Safe only because nothing writes
|
||||||
"color",
|
-- into a spec any more: type, interactive and label are arguments, and a button's
|
||||||
"fill",
|
-- padding is the tree's default rather than something patched in here. Frozen so
|
||||||
"border",
|
-- that reintroducing a write fails at the write instead of leaking a field into
|
||||||
"face",
|
-- every spec-less node built afterwards, which is a fault with no symptom near it.
|
||||||
"pressedFace",
|
local EMPTY = setmetatable({}, {
|
||||||
"pressedColor",
|
__newindex = function()
|
||||||
"focusColor",
|
error("ui specs are read-only during a build", 2)
|
||||||
"radius",
|
end,
|
||||||
"font",
|
})
|
||||||
"textStyle",
|
|
||||||
}
|
|
||||||
|
|
||||||
local function applyStyle(id, spec)
|
local function build(spec, kind, label)
|
||||||
local style, hasStyle = {}, false
|
spec = spec or EMPTY
|
||||||
for _, key in ipairs(STYLE_KEYS) do
|
-- Nodes outside a build would reset the arena under the screen already on the
|
||||||
if spec[key] ~= nil then
|
-- panel, chrome included. Rebuilding is the only way to change one.
|
||||||
style[key], hasStyle = spec[key], true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if spec.background ~= nil then
|
|
||||||
style.background, style.fill, hasStyle = spec.background, spec.background, true
|
|
||||||
end
|
|
||||||
if hasStyle then
|
|
||||||
node.setStyle(id, style)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function build(spec, kind)
|
|
||||||
spec = spec or {}
|
|
||||||
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 = {}
|
-- Nothing is written into the spec: a table constructor sizes its hash part to
|
||||||
for index, child in ipairs(spec) do
|
-- exactly the keys given, so adding type and interactive rehashed most specs --
|
||||||
children[index] = child
|
-- twice for the small ones -- on the build path that peaks the heap. Children
|
||||||
spec[index] = nil
|
-- come straight off the array part, which tree.create never reads.
|
||||||
|
local id = tree.create(nil, spec, kind,
|
||||||
|
spec.on_enter ~= nil or spec.on_exit ~= nil or spec.on_click ~= nil, label)
|
||||||
|
for _, child in ipairs(spec) do
|
||||||
|
tree.attach(id, child)
|
||||||
end
|
end
|
||||||
|
|
||||||
spec.type = kind
|
|
||||||
spec.interactive = spec.on_enter ~= nil or spec.on_exit ~= nil or spec.on_click ~= nil
|
|
||||||
local id = node.create(nil, spec)
|
|
||||||
for _, child in ipairs(children) do
|
|
||||||
node.attach(id, child)
|
|
||||||
end
|
|
||||||
|
|
||||||
applyStyle(id, spec)
|
|
||||||
enterHandlers[id] = spec.on_enter
|
enterHandlers[id] = spec.on_enter
|
||||||
exitHandlers[id] = spec.on_exit
|
exitHandlers[id] = spec.on_exit
|
||||||
clickHandlers[id] = spec.on_click
|
clickHandlers[id] = spec.on_click
|
||||||
painters[id] = spec.paint
|
painters[id] = spec.paint
|
||||||
pressStyles[id] = spec.press_style ~= false
|
if spec.press_style == false then
|
||||||
|
noPressStyle[id] = true
|
||||||
|
end
|
||||||
|
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,59 +222,77 @@ 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)
|
||||||
spec = spec or {}
|
-- Passed through rather than copied into a fresh {w, h}: tree.create reads what it
|
||||||
return build({ w = spec.w, h = spec.h }, "box")
|
-- needs and ignores the rest.
|
||||||
|
return build(spec or EMPTY, "box")
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param text string
|
---@param text string
|
||||||
---@param spec? UiSpec
|
---@param spec? UiSpec
|
||||||
---@return NodeId
|
---@return NodeId
|
||||||
function ui.text(text, spec)
|
function ui.text(text, spec)
|
||||||
spec = spec or {}
|
-- Writes nothing: the label rides as an argument, so a two-key text spec stays
|
||||||
spec.label = text
|
-- two keys instead of rehashing to four.
|
||||||
spec.textStyle, spec.style = spec.style, nil
|
return build(spec or EMPTY, "text", text)
|
||||||
return build(spec, "text")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param text string
|
---@param text string
|
||||||
---@param spec? UiSpec
|
---@param spec? UiSpec
|
||||||
---@return NodeId
|
---@return NodeId
|
||||||
function ui.label(text, spec)
|
function ui.label(text, spec)
|
||||||
spec = spec or {}
|
spec = spec or EMPTY
|
||||||
local font, style = spec.font or gui.FONT_UI, spec.style or gui.STYLE_NORMAL
|
-- Sized by the intrinsic width tree.create measures from the same text, font and
|
||||||
if spec.fit and gui.getTextWidth(font, text, style) > spec.fit then
|
-- style, so this measures only when the text has to be truncated. Writing w and h
|
||||||
while #text > 1 and gui.getTextWidth(font, text .. "~", style) > spec.fit do
|
-- here measured the string a second time and grew the spec by two keys, which on a
|
||||||
|
-- one-key spec is two rehashes.
|
||||||
|
if spec.fit then
|
||||||
|
local font = spec.font or screen.FONT_UI
|
||||||
|
local style = spec.style or screen.STYLE_NORMAL
|
||||||
|
if screen.getTextWidth(font, text, style) > spec.fit then
|
||||||
|
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)
|
end
|
||||||
spec.h = gui.getFontHeight(font, style)
|
|
||||||
spec.font, spec.fit = font, nil
|
|
||||||
return ui.text(text, spec)
|
return ui.text(text, spec)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param spec UiSpec
|
---@param spec UiSpec
|
||||||
---@return NodeId
|
---@return NodeId
|
||||||
function ui.button(spec)
|
function ui.button(spec)
|
||||||
spec = spec or {}
|
spec = spec or EMPTY
|
||||||
spec.pad = spec.pad or 8
|
|
||||||
spec.align = spec.align or "center"
|
|
||||||
local label, font = spec.label, spec.font
|
local label, font = spec.label, spec.font
|
||||||
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 })
|
-- The button's own spec.label is not read by tree.create, so it needs no
|
||||||
|
-- clearing; the child text node carries the label instead.
|
||||||
|
tree.create(id, font and { font = font } or EMPTY, "text", false, label)
|
||||||
end
|
end
|
||||||
return id
|
return id
|
||||||
end
|
end
|
||||||
@@ -278,16 +306,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 +367,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 +378,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 +397,134 @@ 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)
|
-- A build allocates a spec table per node and drops them all here, and the painter is
|
||||||
|
-- the very next thing to want a large contiguous block for its band. Collecting before
|
||||||
|
-- the repaint rather than after it sizes the band against the heap that exists, not one
|
||||||
|
-- still holding a screen's worth of dead spec tables -- a C++ allocation gets no
|
||||||
|
-- emergency collection the way a failed Lua one does. It 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()
|
||||||
|
screen.clear(ui.theme.background)
|
||||||
|
tree.draw(root)
|
||||||
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 not noPressStyle[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
|
||||||
@@ -395,8 +533,8 @@ local function enter(id, x, y)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function exit(id, x, y)
|
local function exit(id, x, y)
|
||||||
if pressStyles[id] then
|
if not noPressStyle[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 +545,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
|
||||||
|
pendingX = pendingX + (lastX - x)
|
||||||
|
end
|
||||||
|
if flags.y then
|
||||||
|
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
|
if isInside then
|
||||||
enter(target, x, y)
|
enter(responder, x, y)
|
||||||
else
|
else
|
||||||
exit(target, x, y)
|
exit(responder, x, y)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return true
|
return true
|
||||||
@@ -449,28 +624,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
|
||||||
|
flinging[panning] = true
|
||||||
|
end
|
||||||
|
responder, insideResponder, scrollAncestor, dragged, panning = nil, nil, nil, false, nil
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local target, wasActive = responder, insideResponder
|
||||||
|
responder, insideResponder, scrollAncestor = nil, nil, nil
|
||||||
|
if target then
|
||||||
if wasActive then
|
if wasActive then
|
||||||
exit(target, x, y)
|
exit(target, x, y)
|
||||||
end
|
end
|
||||||
|
if inside(target, x, y) then
|
||||||
|
local handler = clickHandlers[target]
|
||||||
if handler then
|
if handler then
|
||||||
handler(target, x, y)
|
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 +669,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 +678,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 +700,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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-38
@@ -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,22 +67,22 @@ 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,
|
||||||
create = function(parent, spec)
|
create = function(parent, spec, kind, interactive, label)
|
||||||
local id = #nodes + 1
|
local id = #nodes + 1
|
||||||
local x = 0
|
local x = 0
|
||||||
if spec.type == "button" then
|
if kind == "button" then
|
||||||
buttonCount = buttonCount + 1
|
buttonCount = buttonCount + 1
|
||||||
x = (buttonCount - 1) * 100
|
x = (buttonCount - 1) * 100
|
||||||
end
|
end
|
||||||
nodes[id] = {
|
nodes[id] = {
|
||||||
parent = parent,
|
parent = parent,
|
||||||
type = spec.type,
|
type = kind,
|
||||||
label = spec.label,
|
label = label,
|
||||||
interactive = spec.interactive,
|
interactive = interactive or false,
|
||||||
rect = { x, 0, 90, 50 },
|
rect = { x, 0, 90, 50 },
|
||||||
pressed = false,
|
pressed = false,
|
||||||
style = {},
|
style = {},
|
||||||
@@ -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
|
||||||
|
ui.mount(function()
|
||||||
|
first = ui.button {
|
||||||
label = "one",
|
label = "one",
|
||||||
on_enter = handler "enter",
|
on_enter = handler "enter",
|
||||||
on_exit = handler "exit",
|
on_exit = handler "exit",
|
||||||
on_click = handler "click",
|
on_click = handler "click",
|
||||||
}
|
}
|
||||||
local second = ui.button {
|
second = ui.button {
|
||||||
label = "two",
|
label = "two",
|
||||||
on_enter = handler "enter",
|
on_enter = handler "enter",
|
||||||
on_exit = handler "exit",
|
on_exit = handler "exit",
|
||||||
on_click = handler "click",
|
on_click = handler "click",
|
||||||
}
|
}
|
||||||
local screen = ui.screen(ui.box { row = true, first, second })
|
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"
|
||||||
|
|||||||
+121
-13
@@ -15,6 +15,7 @@
|
|||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <deque>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -32,10 +33,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 +120,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 +156,7 @@ public:
|
|||||||
labelAt.clear();
|
labelAt.clear();
|
||||||
labels.clear();
|
labels.clear();
|
||||||
styles.clear();
|
styles.clear();
|
||||||
|
scrollState.clear();
|
||||||
error = nullptr;
|
error = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,8 +204,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 +224,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
|
||||||
@@ -301,7 +371,26 @@ public:
|
|||||||
private:
|
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::deque<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 +434,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 +478,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) {
|
||||||
|
|||||||
+121
-16
@@ -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.
|
||||||
@@ -171,6 +197,61 @@ struct HttpDownload {
|
|||||||
std::string sha256; // Empty when the caller did not declare one.
|
std::string sha256; // Empty when the caller did not declare one.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct HttpServe {
|
||||||
|
int32_t port = 80;
|
||||||
|
std::string root; // Static directory; empty serves no files.
|
||||||
|
std::string hostname; // mDNS name; empty advertises nothing.
|
||||||
|
// Header names the app wants to read. The parser is told before a request
|
||||||
|
// arrives, so a header nobody asked for is never retained.
|
||||||
|
std::vector<std::string> headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One request the server is answering. The body has not been read yet when the
|
||||||
|
// handler sees this, which is the whole point: the exchange is decided from the
|
||||||
|
// headers, before a byte reaches the card.
|
||||||
|
struct HttpServerRequest {
|
||||||
|
std::string method;
|
||||||
|
std::string path;
|
||||||
|
std::vector<HttpHeader> headers;
|
||||||
|
// Query parameters, plus the fields of an urlencoded form. Empty once a body
|
||||||
|
// streams, because the server discards the query string to read it -- so an
|
||||||
|
// upload names what it is in the path or a header.
|
||||||
|
std::vector<HttpHeader> args;
|
||||||
|
int32_t length = 0; // Content-Length; zero when chunked or absent.
|
||||||
|
std::string filename; // Multipart's name, flattened to a basename.
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HttpReply {
|
||||||
|
int32_t status = 200;
|
||||||
|
std::vector<HttpHeader> headers;
|
||||||
|
std::string body;
|
||||||
|
std::string streamFrom; // Sent off the card; exclusive with body.
|
||||||
|
};
|
||||||
|
|
||||||
|
// The handler's answer: the reply, plus where the request body goes.
|
||||||
|
struct HttpExchange : HttpReply {
|
||||||
|
std::string streamTo; // Empty drains the body unread.
|
||||||
|
bool deferred = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HttpStreamResult {
|
||||||
|
bool ok = true;
|
||||||
|
int32_t bytes = 0;
|
||||||
|
std::string path;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Answered by the runtime on the Lua task, from inside HttpProvider::poll(), so
|
||||||
|
// a handler runs between batches exactly as a timer callback does.
|
||||||
|
class HttpHandler {
|
||||||
|
public:
|
||||||
|
virtual ~HttpHandler() = default;
|
||||||
|
virtual void handleRequest(const HttpServerRequest& request,
|
||||||
|
HttpExchange& exchange) = 0;
|
||||||
|
// Fires once per deferred exchange, after the body has landed.
|
||||||
|
virtual void handleStreamed(const HttpStreamResult& result,
|
||||||
|
HttpReply& reply) = 0;
|
||||||
|
};
|
||||||
|
|
||||||
class HttpProvider {
|
class HttpProvider {
|
||||||
public:
|
public:
|
||||||
virtual ~HttpProvider() = default;
|
virtual ~HttpProvider() = default;
|
||||||
@@ -182,6 +263,12 @@ public:
|
|||||||
const std::string& destination,
|
const std::string& destination,
|
||||||
const HttpDownload& options,
|
const HttpDownload& options,
|
||||||
int32_t& bytesWritten) = 0;
|
int32_t& bytesWritten) = 0;
|
||||||
|
// Serves until stopServing(). Request bodies stream to the path the handler
|
||||||
|
// names and responses stream off the card, so neither passes through the
|
||||||
|
// heap; poll() is called from the firmware loop on the Lua task.
|
||||||
|
virtual Status serve(const HttpServe& options, HttpHandler* handler) = 0;
|
||||||
|
virtual void stopServing() = 0;
|
||||||
|
virtual void poll() = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
using TimerId = int32_t;
|
using TimerId = int32_t;
|
||||||
@@ -220,10 +307,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 +333,11 @@ 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 bool nextObservation(const std::string* after,
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
class Runtime {
|
// 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 : public HttpHandler {
|
||||||
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);
|
||||||
@@ -117,6 +108,14 @@ public:
|
|||||||
// Called on the Lua thread when a scheduled deadline elapses.
|
// Called on the Lua thread when a scheduled deadline elapses.
|
||||||
void callTimer(TimerId id);
|
void callTimer(TimerId id);
|
||||||
|
|
||||||
|
// The retained http.serve handler. Zero forgets it, which is what
|
||||||
|
// http.stopServing() and closing the state both do.
|
||||||
|
void setRequestHandler(int callbackRef);
|
||||||
|
void handleRequest(const HttpServerRequest& request,
|
||||||
|
HttpExchange& exchange) override;
|
||||||
|
void handleStreamed(const HttpStreamResult& result,
|
||||||
|
HttpReply& reply) override;
|
||||||
|
|
||||||
static Runtime* from(lua_State* state);
|
static Runtime* from(lua_State* state);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -125,18 +124,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,10 +153,13 @@ 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();
|
||||||
|
// Reads a reply table at the top of the stack, leaving it there.
|
||||||
|
void readReply(HttpReply& reply);
|
||||||
|
|
||||||
Providers providers_;
|
Providers providers_;
|
||||||
lua_State* state_ = nullptr;
|
lua_State* state_ = nullptr;
|
||||||
@@ -167,10 +168,14 @@ 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;
|
||||||
|
// Registry references to http.serve's handler and to the done() of the
|
||||||
|
// exchange currently receiving a body.
|
||||||
|
int requestRef_ = 0;
|
||||||
|
int doneRef_ = 0;
|
||||||
std::string appPath_;
|
std::string appPath_;
|
||||||
std::string appTitle_;
|
|
||||||
std::vector<Route> history_;
|
|
||||||
Pending pending_;
|
Pending pending_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
// @lua-module ble BleLib
|
// @lua-module ble BleLib
|
||||||
// @lua-preamble ---@class BleDevice
|
// @lua-preamble ---@alias BleObservationIterator fun(state: nil, after:
|
||||||
// @lua-preamble ---@field name string
|
// string|nil): address: string|nil, name: string|nil, rssi: integer|nil,
|
||||||
// @lua-preamble ---@field address string
|
// payload: string|nil, lastSeenMs: integer|nil
|
||||||
// @lua-preamble ---@field rssi integer
|
// @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,23 +33,84 @@ 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) {
|
||||||
|
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_TNUMBER)
|
||||||
|
out.push_back(static_cast<int32_t>(lua_tointeger(state, -1)));
|
||||||
|
lua_pop(state, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lua_pop(state, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
int nextObservation(lua_State* state) {
|
||||||
|
BleProvider& provider = Runtime::from(state)->ble();
|
||||||
|
if (!provider.isObserving())
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
std::string after;
|
||||||
|
const std::string* cursor = nullptr;
|
||||||
|
if (!lua_isnoneornil(state, 2)) {
|
||||||
|
after = checkString(state, 2);
|
||||||
|
cursor = &after;
|
||||||
|
}
|
||||||
|
|
||||||
|
BleObservation observation;
|
||||||
|
if (!provider.nextObservation(cursor, observation))
|
||||||
|
return 0;
|
||||||
|
pushString(state, observation.address);
|
||||||
|
pushString(state, observation.name);
|
||||||
|
lua_pushinteger(state, observation.rssi);
|
||||||
|
pushString(state, observation.payload);
|
||||||
|
lua_pushinteger(state, observation.lastSeenMs);
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
int observe(lua_State* state) {
|
||||||
|
BleProvider& provider = Runtime::from(state)->ble();
|
||||||
|
if (provider.isObserving())
|
||||||
|
return pushError(state, "already observing");
|
||||||
|
|
||||||
|
BleFilter filter;
|
||||||
|
if (lua_istable(state, 1)) {
|
||||||
|
readStringArray(state, 1, "services", filter.services);
|
||||||
|
readIntArray(state, 1, "manufacturers", filter.manufacturers);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Status status = provider.observe(filter);
|
||||||
if (!status.ok)
|
if (!status.ok)
|
||||||
return pushError(state, status.error);
|
return pushError(state, status.error);
|
||||||
|
lua_pushcfunction(state, nextObservation);
|
||||||
lua_createtable(state, static_cast<int>(devices.size()), 0);
|
return 1;
|
||||||
for (size_t at = 0; at < devices.size(); at++) {
|
|
||||||
lua_createtable(state, 0, 3);
|
|
||||||
setField(state, "name", devices[at].name);
|
|
||||||
setField(state, "address", devices[at].address);
|
|
||||||
setField(state, "rssi", devices[at].rssi);
|
|
||||||
lua_rawseti(state, -2, static_cast<lua_Integer>(at + 1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,11 +172,17 @@ 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.
|
// The returned iterator is reusable; observation stops with its Lua state.
|
||||||
// @return BleDevice[]|nil devices
|
// @param filter BleFilter|nil Keep only matching adverts; nil keeps all.
|
||||||
|
// @return BleObservationIterator|nil observations
|
||||||
// @return string|nil error
|
// @return string|nil error
|
||||||
{"scan", scan},
|
{"observe", observe},
|
||||||
|
// --- Stops observing and clears the buffered devices.
|
||||||
|
{"unobserve", unobserve},
|
||||||
|
// --- Whether advertisement observation is running.
|
||||||
|
// @return boolean
|
||||||
|
{"isObserving", isObserving},
|
||||||
// --- Connects to a peripheral.
|
// --- Connects to a peripheral.
|
||||||
// @param address string
|
// @param address string
|
||||||
// @return true|nil ok
|
// @return true|nil ok
|
||||||
|
|||||||
@@ -15,6 +15,48 @@
|
|||||||
// @lua-preamble ---@field expectedSize? integer Exact expected byte count.
|
// @lua-preamble ---@field expectedSize? integer Exact expected byte count.
|
||||||
// @lua-preamble ---@field sha256? string Exact expected SHA-256 as 64
|
// @lua-preamble ---@field sha256? string Exact expected SHA-256 as 64
|
||||||
// hexadecimal characters.
|
// hexadecimal characters.
|
||||||
|
// @lua-preamble
|
||||||
|
// @lua-preamble ---@class HttpServer
|
||||||
|
// @lua-preamble ---@field root? string Directory of static files, answered
|
||||||
|
// only where onRequest declined, so an app can override any file in it.
|
||||||
|
// @lua-preamble ---@field port? integer Defaults to 80.
|
||||||
|
// @lua-preamble ---@field hostname? string Advertised over mDNS as
|
||||||
|
// <hostname>.local; omitted advertises nothing.
|
||||||
|
// @lua-preamble ---@field headers? string[] Request headers to collect, named
|
||||||
|
// up front because the parser keeps no others. Authorization is always kept.
|
||||||
|
// @lua-preamble ---@field onRequest? fun(req: HttpServerRequest):
|
||||||
|
// HttpExchange|nil
|
||||||
|
// @lua-preamble
|
||||||
|
// @lua-preamble ---@class HttpServerRequest
|
||||||
|
// @lua-preamble ---@field method string
|
||||||
|
// @lua-preamble ---@field path string Path only, without the query string.
|
||||||
|
// @lua-preamble ---@field headers table<string, string> Lowercased names.
|
||||||
|
// @lua-preamble ---@field args table<string, string> Query parameters, plus
|
||||||
|
// an urlencoded form's fields; empty once a body streams, so an upload names
|
||||||
|
// what it is in the path or a header.
|
||||||
|
// @lua-preamble ---@field length integer Content-Length; zero when chunked or
|
||||||
|
// absent.
|
||||||
|
// @lua-preamble ---@field filename string|nil Multipart's original name,
|
||||||
|
// flattened to a basename.
|
||||||
|
// @lua-preamble
|
||||||
|
// @lua-preamble ---@class HttpReply
|
||||||
|
// @lua-preamble ---@field status? integer Defaults to 200.
|
||||||
|
// @lua-preamble ---@field headers? table<string, string> Content-Type lives
|
||||||
|
// here; there is no second way to set it.
|
||||||
|
// @lua-preamble ---@field body? string Response from memory.
|
||||||
|
// @lua-preamble ---@field streamFrom? string Response sent off the card;
|
||||||
|
// exclusive with body.
|
||||||
|
// @lua-preamble
|
||||||
|
// @lua-preamble ---@class HttpExchange : HttpReply
|
||||||
|
// @lua-preamble ---@field streamTo? string Request body written to this path,
|
||||||
|
// in chunks; omitted drains it unread.
|
||||||
|
// @lua-preamble ---@field done? fun(result: HttpResult): HttpReply|nil Reply
|
||||||
|
// computed once the body has landed; exclusive with the reply fields.
|
||||||
|
// @lua-preamble
|
||||||
|
// @lua-preamble ---@class HttpResult
|
||||||
|
// @lua-preamble ---@field ok boolean False when the write failed.
|
||||||
|
// @lua-preamble ---@field bytes integer
|
||||||
|
// @lua-preamble ---@field path string
|
||||||
// @lua-const MAX_RESPONSE_BYTES integer 65536 Largest portable in-memory
|
// @lua-const MAX_RESPONSE_BYTES integer 65536 Largest portable in-memory
|
||||||
// response body.
|
// response body.
|
||||||
|
|
||||||
@@ -118,6 +160,58 @@ int download(lua_State* state) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int serve(lua_State* state) {
|
||||||
|
luaL_checktype(state, 1, LUA_TTABLE);
|
||||||
|
HttpServe options;
|
||||||
|
lua_getfield(state, 1, "port");
|
||||||
|
options.port = static_cast<int32_t>(luaL_optinteger(state, -1, 80));
|
||||||
|
lua_pop(state, 1);
|
||||||
|
luaL_argcheck(state, options.port > 0 && options.port <= 65535, 1,
|
||||||
|
"port must be 1 through 65535");
|
||||||
|
lua_getfield(state, 1, "root");
|
||||||
|
if (!lua_isnil(state, -1))
|
||||||
|
options.root = checkString(state, -1);
|
||||||
|
lua_pop(state, 1);
|
||||||
|
lua_getfield(state, 1, "hostname");
|
||||||
|
if (!lua_isnil(state, -1))
|
||||||
|
options.hostname = checkString(state, -1);
|
||||||
|
lua_pop(state, 1);
|
||||||
|
lua_getfield(state, 1, "headers");
|
||||||
|
if (lua_istable(state, -1)) {
|
||||||
|
const lua_Integer count = lua_rawlen(state, -1);
|
||||||
|
for (lua_Integer at = 1; at <= count; at++) {
|
||||||
|
lua_rawgeti(state, -1, at);
|
||||||
|
options.headers.push_back(checkString(state, -1));
|
||||||
|
lua_pop(state, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lua_pop(state, 1);
|
||||||
|
|
||||||
|
lua_getfield(state, 1, "onRequest");
|
||||||
|
const bool hasHandler = lua_isfunction(state, -1);
|
||||||
|
luaL_argcheck(state, hasHandler || !options.root.empty(), 1,
|
||||||
|
"serve needs root, onRequest, or both");
|
||||||
|
int handlerRef = 0;
|
||||||
|
if (hasHandler)
|
||||||
|
handlerRef = luaL_ref(state, LUA_REGISTRYINDEX);
|
||||||
|
else
|
||||||
|
lua_pop(state, 1);
|
||||||
|
|
||||||
|
Runtime* runtime = Runtime::from(state);
|
||||||
|
runtime->setRequestHandler(handlerRef);
|
||||||
|
const Status status = runtime->http().serve(options, runtime);
|
||||||
|
if (!status.ok)
|
||||||
|
runtime->setRequestHandler(0);
|
||||||
|
return pushStatus(state, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
int stopServing(lua_State* state) {
|
||||||
|
Runtime* runtime = Runtime::from(state);
|
||||||
|
runtime->http().stopServing();
|
||||||
|
runtime->setRequestHandler(0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
int urlencode(lua_State* state) {
|
int urlencode(lua_State* state) {
|
||||||
static const char* HEX = "0123456789ABCDEF";
|
static const char* HEX = "0123456789ABCDEF";
|
||||||
size_t length = 0;
|
size_t length = 0;
|
||||||
@@ -184,6 +278,16 @@ const luaL_Reg FUNCTIONS[] = {
|
|||||||
// @return integer|nil bytesWritten
|
// @return integer|nil bytesWritten
|
||||||
// @return string|nil error
|
// @return string|nil error
|
||||||
{"download", download},
|
{"download", download},
|
||||||
|
// --- Serves one connection at a time until stopServing(), or until the
|
||||||
|
// app's state closes. Request bodies stream to the card and responses
|
||||||
|
// stream off it, so neither passes through the heap. Answering runs on the
|
||||||
|
// Lua task, so a large upload holds the frame.
|
||||||
|
// @param options HttpServer
|
||||||
|
// @return boolean|nil ok
|
||||||
|
// @return string|nil error Wifi is down, or the port is taken.
|
||||||
|
{"serve", serve},
|
||||||
|
// --- Closes the listener and drops the handler.
|
||||||
|
{"stopServing", stopServing},
|
||||||
// --- Percent-encodes a string for use in a URL.
|
// --- Percent-encodes a string for use in a URL.
|
||||||
// @param input string
|
// @param input string
|
||||||
// @return string
|
// @return string
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -100,9 +99,10 @@ bool readFlag(lua_State* state, int index, const char* key) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui::Align readAlign(lua_State* state, int index, const char* key) {
|
ui::Align readAlign(lua_State* state, int index, const char* key,
|
||||||
|
ui::Align fallback = ui::START) {
|
||||||
lua_getfield(state, index, key);
|
lua_getfield(state, index, key);
|
||||||
ui::Align align = ui::START;
|
ui::Align align = fallback;
|
||||||
if (!lua_isnoneornil(state, -1)) {
|
if (!lua_isnoneornil(state, -1)) {
|
||||||
const char* value = luaL_checkstring(state, -1);
|
const char* value = luaL_checkstring(state, -1);
|
||||||
if (strcmp(value, "center") == 0) {
|
if (strcmp(value, "center") == 0) {
|
||||||
@@ -122,9 +122,12 @@ ui::Align readAlign(lua_State* state, int index, const char* key) {
|
|||||||
return align;
|
return align;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Taken as arguments rather than spec fields: writing type and interactive into
|
||||||
|
// the caller's table rehashed it, because a constructor sizes the hash part to
|
||||||
|
// exactly the keys written and two more keys overflow it. Interactive is the
|
||||||
|
// caller's to decide, so the tree stays ignorant of what names handlers go by.
|
||||||
uint8_t readType(lua_State* state, int index) {
|
uint8_t readType(lua_State* state, int index) {
|
||||||
lua_getfield(state, index, "type");
|
const char* value = luaL_checkstring(state, index);
|
||||||
const char* value = luaL_checkstring(state, -1);
|
|
||||||
uint8_t type = ui::BOX;
|
uint8_t type = ui::BOX;
|
||||||
if (strcmp(value, "text") == 0) {
|
if (strcmp(value, "text") == 0) {
|
||||||
type = ui::TEXT;
|
type = ui::TEXT;
|
||||||
@@ -133,10 +136,8 @@ uint8_t readType(lua_State* state, int index) {
|
|||||||
} else if (strcmp(value, "custom") == 0) {
|
} else if (strcmp(value, "custom") == 0) {
|
||||||
type = ui::CUSTOM;
|
type = ui::CUSTOM;
|
||||||
} else if (strcmp(value, "box") != 0) {
|
} else if (strcmp(value, "box") != 0) {
|
||||||
lua_pop(state, 1);
|
|
||||||
luaL_error(state, "unknown node type '%s'", value);
|
luaL_error(state, "unknown node type '%s'", value);
|
||||||
}
|
}
|
||||||
lua_pop(state, 1);
|
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,20 +146,67 @@ int reset(lua_State* state) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void readStyleColor(lua_State* state, int index, const char* key,
|
||||||
|
int32_t& field, uint16_t flag, uint16_t& set) {
|
||||||
|
lua_getfield(state, index, key);
|
||||||
|
if (!lua_isnoneornil(state, -1)) {
|
||||||
|
field = static_cast<int32_t>(luaL_checkinteger(state, -1));
|
||||||
|
set |= flag;
|
||||||
|
}
|
||||||
|
lua_pop(state, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads whatever style fields a spec carries, returning whether it named any. The
|
||||||
|
// Lua wrapper used to do this by copying the style keys into a second table and
|
||||||
|
// mapping background itself, which cost a table per styled node and a rehash of the
|
||||||
|
// caller's spec to express the mapping.
|
||||||
|
bool readStyleInto(lua_State* state, int index, ui::Style& style) {
|
||||||
|
const uint16_t before = style.set;
|
||||||
|
readStyleColor(state, index, "color", style.color, ui::S_COLOR, style.set);
|
||||||
|
readStyleColor(state, index, "background", style.bg, ui::S_BG, style.set);
|
||||||
|
readStyleColor(state, index, "fill", style.fill, ui::S_FILL, style.set);
|
||||||
|
readStyleColor(state, index, "border", style.border, ui::S_BORDER, style.set);
|
||||||
|
readStyleColor(state, index, "face", style.face, ui::S_FACE, style.set);
|
||||||
|
readStyleColor(state, index, "pressedFace", style.pressedFace,
|
||||||
|
ui::S_PRESSED_FACE, style.set);
|
||||||
|
readStyleColor(state, index, "pressedColor", style.pressedColor,
|
||||||
|
ui::S_PRESSED_COLOR, style.set);
|
||||||
|
readStyleColor(state, index, "focusColor", style.focusColor,
|
||||||
|
ui::S_FOCUS_COLOR, style.set);
|
||||||
|
readStyleColor(state, index, "font", style.font, ui::S_FONT, style.set);
|
||||||
|
readStyleColor(state, index, "textStyle", style.textStyle, ui::S_TEXT_STYLE,
|
||||||
|
style.set);
|
||||||
|
|
||||||
|
int32_t radius = style.radius;
|
||||||
|
readStyleColor(state, index, "radius", radius, ui::S_RADIUS, style.set);
|
||||||
|
style.radius = static_cast<uint8_t>(radius);
|
||||||
|
|
||||||
|
// A background is the surface its own corners blend into, so it implies fill.
|
||||||
|
// An explicit fill wins, which is the way round the Lua version had it backwards.
|
||||||
|
if ((style.set & ui::S_BG) && !(style.set & ui::S_FILL)) {
|
||||||
|
style.fill = style.bg;
|
||||||
|
style.set |= ui::S_FILL;
|
||||||
|
}
|
||||||
|
return style.set != before;
|
||||||
|
}
|
||||||
|
|
||||||
int create(lua_State* state) {
|
int create(lua_State* state) {
|
||||||
const bool hasParent = !lua_isnoneornil(state, 1);
|
const bool hasParent = !lua_isnoneornil(state, 1);
|
||||||
const uint16_t parent = hasParent ? checkNode(state, 1) : ui::NONE;
|
const uint16_t parent = hasParent ? checkNode(state, 1) : ui::NONE;
|
||||||
luaL_checktype(state, 2, LUA_TTABLE);
|
luaL_checktype(state, 2, LUA_TTABLE);
|
||||||
|
|
||||||
const uint8_t type = readType(state, 2);
|
const uint8_t type = readType(state, 3);
|
||||||
ui::Spec spec;
|
ui::Spec spec;
|
||||||
bool present = false;
|
bool present = false;
|
||||||
spec.w = readSize(state, 2, "w", present);
|
spec.w = readSize(state, 2, "w", present);
|
||||||
spec.h = readSize(state, 2, "h", present);
|
spec.h = readSize(state, 2, "h", present);
|
||||||
const int16_t pad = readNumber(state, 2, "pad", 0);
|
// A button pads and centres its label by default, which the Lua wrapper used to
|
||||||
|
// do by writing both into the caller's spec and rehashing it.
|
||||||
|
const bool button = type == ui::BUTTON;
|
||||||
|
const int16_t pad = readNumber(state, 2, "pad", button ? 8 : 0);
|
||||||
spec.padT = spec.padR = spec.padB = spec.padL = static_cast<uint8_t>(pad);
|
spec.padT = spec.padR = spec.padB = spec.padL = static_cast<uint8_t>(pad);
|
||||||
spec.gap = static_cast<uint8_t>(readNumber(state, 2, "gap", 0));
|
spec.gap = static_cast<uint8_t>(readNumber(state, 2, "gap", 0));
|
||||||
spec.align = readAlign(state, 2, "align");
|
spec.align = readAlign(state, 2, "align", button ? ui::CENTER : ui::START);
|
||||||
spec.justify = readAlign(state, 2, "justify");
|
spec.justify = readAlign(state, 2, "justify");
|
||||||
|
|
||||||
lua_getfield(state, 2, "at");
|
lua_getfield(state, 2, "at");
|
||||||
@@ -173,10 +221,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"))
|
if (lua_toboolean(state, 4))
|
||||||
flags |= ui::CAPTURE;
|
|
||||||
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)
|
||||||
@@ -184,12 +234,19 @@ int create(lua_State* state) {
|
|||||||
: static_cast<int32_t>(luaL_checkinteger(state, -1));
|
: static_cast<int32_t>(luaL_checkinteger(state, -1));
|
||||||
lua_pop(state, 1);
|
lua_pop(state, 1);
|
||||||
|
|
||||||
lua_getfield(state, 2, "label");
|
// An argument for the same reason type is: a text node's spec is small, so the
|
||||||
|
// label was usually the key that pushed it into a rehash.
|
||||||
const char* label =
|
const char* label =
|
||||||
lua_isnoneornil(state, -1) ? nullptr : luaL_checkstring(state, -1);
|
lua_isnoneornil(state, 5) ? nullptr : luaL_checkstring(state, 5);
|
||||||
if (label && type == ui::TEXT) {
|
if (label && type == ui::TEXT) {
|
||||||
GuiProvider& gui = Runtime::from(state)->gui();
|
GuiProvider& gui = Runtime::from(state)->gui();
|
||||||
const int32_t style = gui.fonts().styleNormal;
|
// Measured with the style it will be painted in, not the normal one: a bold
|
||||||
|
// label is wider than its own box otherwise.
|
||||||
|
lua_getfield(state, 2, "textStyle");
|
||||||
|
const int32_t style = lua_isnoneornil(state, -1)
|
||||||
|
? gui.fonts().styleNormal
|
||||||
|
: static_cast<int32_t>(luaL_checkinteger(state, -1));
|
||||||
|
lua_pop(state, 1);
|
||||||
spec.intrinsicW = static_cast<int16_t>(gui.textWidth(font, label, style));
|
spec.intrinsicW = static_cast<int16_t>(gui.textWidth(font, label, style));
|
||||||
spec.intrinsicH = static_cast<int16_t>(gui.fontHeight(font, style));
|
spec.intrinsicH = static_cast<int16_t>(gui.fontHeight(font, style));
|
||||||
}
|
}
|
||||||
@@ -197,7 +254,14 @@ int create(lua_State* state) {
|
|||||||
const uint16_t id = tree(state).add(parent, spec, type, flags);
|
const uint16_t id = tree(state).add(parent, spec, type, flags);
|
||||||
if (label)
|
if (label)
|
||||||
tree(state).setLabel(id, label);
|
tree(state).setLabel(id, label);
|
||||||
lua_pop(state, 1);
|
|
||||||
|
// Styled here rather than by a second call from Lua: the spec is already on the
|
||||||
|
// stack, and a node naming no colours must cost no Style entry, which is a
|
||||||
|
// decision the set mask makes better than a loop over key names in Lua.
|
||||||
|
ui::Style style;
|
||||||
|
if (readStyleInto(state, 2, style))
|
||||||
|
tree(state).styleFor(id) = style;
|
||||||
|
|
||||||
lua_pushinteger(state, id);
|
lua_pushinteger(state, id);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -270,6 +334,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);
|
||||||
@@ -294,39 +379,11 @@ int getParent(lua_State* state) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
void readStyleColor(lua_State* state, int index, const char* key,
|
|
||||||
int32_t& field, uint16_t flag, uint16_t& set) {
|
|
||||||
lua_getfield(state, index, key);
|
|
||||||
if (!lua_isnoneornil(state, -1)) {
|
|
||||||
field = static_cast<int32_t>(luaL_checkinteger(state, -1));
|
|
||||||
set |= flag;
|
|
||||||
}
|
|
||||||
lua_pop(state, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
int setStyle(lua_State* state) {
|
int setStyle(lua_State* state) {
|
||||||
const uint16_t id = checkNode(state, 1);
|
const uint16_t id = checkNode(state, 1);
|
||||||
luaL_checktype(state, 2, LUA_TTABLE);
|
luaL_checktype(state, 2, LUA_TTABLE);
|
||||||
|
|
||||||
ui::Style& style = tree(state).styleFor(id);
|
readStyleInto(state, 2, tree(state).styleFor(id));
|
||||||
readStyleColor(state, 2, "color", style.color, ui::S_COLOR, style.set);
|
|
||||||
readStyleColor(state, 2, "background", style.bg, ui::S_BG, style.set);
|
|
||||||
readStyleColor(state, 2, "fill", style.fill, ui::S_FILL, style.set);
|
|
||||||
readStyleColor(state, 2, "border", style.border, ui::S_BORDER, style.set);
|
|
||||||
readStyleColor(state, 2, "face", style.face, ui::S_FACE, style.set);
|
|
||||||
readStyleColor(state, 2, "pressedFace", style.pressedFace, ui::S_PRESSED_FACE,
|
|
||||||
style.set);
|
|
||||||
readStyleColor(state, 2, "pressedColor", style.pressedColor,
|
|
||||||
ui::S_PRESSED_COLOR, style.set);
|
|
||||||
readStyleColor(state, 2, "focusColor", style.focusColor, ui::S_FOCUS_COLOR,
|
|
||||||
style.set);
|
|
||||||
readStyleColor(state, 2, "font", style.font, ui::S_FONT, style.set);
|
|
||||||
readStyleColor(state, 2, "textStyle", style.textStyle, ui::S_TEXT_STYLE,
|
|
||||||
style.set);
|
|
||||||
|
|
||||||
int32_t radius = style.radius;
|
|
||||||
readStyleColor(state, 2, "radius", radius, ui::S_RADIUS, style.set);
|
|
||||||
style.radius = static_cast<uint8_t>(radius);
|
|
||||||
|
|
||||||
tree(state).nodes[id].flags |= ui::DIRTY;
|
tree(state).nodes[id].flags |= ui::DIRTY;
|
||||||
return 0;
|
return 0;
|
||||||
@@ -467,7 +524,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 +537,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 +616,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 +674,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 +696,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
|
||||||
@@ -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?
|
||||||
|
|||||||
@@ -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
+221
-21
@@ -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.
|
||||||
if (custom)
|
const int x0 = n.x > clipX ? n.x : clipX;
|
||||||
custom(context, id, n.x, n.y, n.w, n.h);
|
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;
|
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)
|
||||||
|
custom(context, id, n.x, n.y, n.w, n.h, x0, y0, x1 - x0, y1 - y0);
|
||||||
|
popClip(previous);
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
+285
-100
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,21 +65,38 @@ bool Runtime::open() {
|
|||||||
if (!state_)
|
if (!state_)
|
||||||
return false;
|
return false;
|
||||||
*static_cast<Runtime**>(lua_getextraspace(state_)) = this;
|
*static_cast<Runtime**>(lua_getextraspace(state_)) = this;
|
||||||
luaL_openlibs(state_);
|
// Only the libraries the Lua sources here actually reference. io, coroutine,
|
||||||
|
// utf8 and debug have zero call sites across every module and app, and each
|
||||||
|
// one's tables and closures are live heap in every app's state.
|
||||||
|
static const luaL_Reg kLibs[] = {
|
||||||
|
{LUA_GNAME, luaopen_base},
|
||||||
|
{LUA_LOADLIBNAME, luaopen_package},
|
||||||
|
{LUA_TABLIBNAME, luaopen_table},
|
||||||
|
{LUA_STRLIBNAME, luaopen_string},
|
||||||
|
{LUA_MATHLIBNAME, luaopen_math},
|
||||||
|
{LUA_OSLIBNAME, luaopen_os},
|
||||||
|
{nullptr, nullptr},
|
||||||
|
};
|
||||||
|
for (const luaL_Reg* lib = kLibs; lib->func; lib++) {
|
||||||
|
luaL_requiref(state_, lib->name, lib->func, 1);
|
||||||
|
lua_pop(state_, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
@@ -89,13 +108,16 @@ void Runtime::close() {
|
|||||||
if (!state_)
|
if (!state_)
|
||||||
return;
|
return;
|
||||||
cancelAllTimers();
|
cancelAllTimers();
|
||||||
|
providers_.ble->unobserve();
|
||||||
|
providers_.http->stopServing();
|
||||||
|
setRequestHandler(0);
|
||||||
|
luaL_unref(state_, LUA_REGISTRYINDEX, doneRef_);
|
||||||
|
doneRef_ = 0;
|
||||||
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 +125,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 +163,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 +181,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 +226,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 +252,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;
|
||||||
@@ -266,6 +312,163 @@ void Runtime::callTimer(TimerId id) {
|
|||||||
luaL_unref(state_, LUA_REGISTRYINDEX, callbackRef);
|
luaL_unref(state_, LUA_REGISTRYINDEX, callbackRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void pushHeaders(lua_State* state, const std::vector<HttpHeader>& headers) {
|
||||||
|
lua_createtable(state, 0, static_cast<int>(headers.size()));
|
||||||
|
for (size_t at = 0; at < headers.size(); at++) {
|
||||||
|
lua_pushlstring(state, headers[at].value.data(), headers[at].value.size());
|
||||||
|
lua_setfield(state, -2, headers[at].name.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void readHeaders(lua_State* state, std::vector<HttpHeader>& headers) {
|
||||||
|
lua_getfield(state, -1, "headers");
|
||||||
|
if (lua_istable(state, -1)) {
|
||||||
|
lua_pushnil(state);
|
||||||
|
while (lua_next(state, -2)) {
|
||||||
|
if (lua_type(state, -2) == LUA_TSTRING &&
|
||||||
|
lua_type(state, -1) == LUA_TSTRING) {
|
||||||
|
HttpHeader header;
|
||||||
|
header.name = lua_tostring(state, -2);
|
||||||
|
header.value = lua_tostring(state, -1);
|
||||||
|
headers.push_back(header);
|
||||||
|
}
|
||||||
|
lua_pop(state, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lua_pop(state, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool readOptionalString(lua_State* state, const char* key, std::string& out) {
|
||||||
|
lua_getfield(state, -1, key);
|
||||||
|
const bool present = lua_type(state, -1) == LUA_TSTRING;
|
||||||
|
if (present)
|
||||||
|
out = lua_tostring(state, -1);
|
||||||
|
lua_pop(state, 1);
|
||||||
|
return present;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void Runtime::readReply(HttpReply& reply) {
|
||||||
|
lua_getfield(state_, -1, "status");
|
||||||
|
reply.status = static_cast<int32_t>(luaL_optinteger(state_, -1, 200));
|
||||||
|
lua_pop(state_, 1);
|
||||||
|
readHeaders(state_, reply.headers);
|
||||||
|
const bool hasBody = readOptionalString(state_, "body", reply.body);
|
||||||
|
if (readOptionalString(state_, "streamFrom", reply.streamFrom) && hasBody) {
|
||||||
|
providers_.log->write(LogLevel::Error,
|
||||||
|
"onRequest: body and streamFrom are exclusive");
|
||||||
|
reply.status = 500;
|
||||||
|
reply.body.clear();
|
||||||
|
reply.streamFrom.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Runtime::setRequestHandler(int callbackRef) {
|
||||||
|
if (requestRef_ != 0)
|
||||||
|
luaL_unref(state_, LUA_REGISTRYINDEX, requestRef_);
|
||||||
|
requestRef_ = callbackRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Runtime::handleRequest(const HttpServerRequest& request,
|
||||||
|
HttpExchange& exchange) {
|
||||||
|
exchange.status = 404;
|
||||||
|
if (!state_ || requestRef_ == 0)
|
||||||
|
return;
|
||||||
|
const Batch batch(*this);
|
||||||
|
|
||||||
|
lua_rawgeti(state_, LUA_REGISTRYINDEX, requestRef_);
|
||||||
|
lua_createtable(state_, 0, 6);
|
||||||
|
lua_pushlstring(state_, request.method.data(), request.method.size());
|
||||||
|
lua_setfield(state_, -2, "method");
|
||||||
|
lua_pushlstring(state_, request.path.data(), request.path.size());
|
||||||
|
lua_setfield(state_, -2, "path");
|
||||||
|
pushHeaders(state_, request.args);
|
||||||
|
lua_setfield(state_, -2, "args");
|
||||||
|
lua_pushinteger(state_, request.length);
|
||||||
|
lua_setfield(state_, -2, "length");
|
||||||
|
if (!request.filename.empty()) {
|
||||||
|
lua_pushlstring(state_, request.filename.data(), request.filename.size());
|
||||||
|
lua_setfield(state_, -2, "filename");
|
||||||
|
}
|
||||||
|
pushHeaders(state_, request.headers);
|
||||||
|
lua_setfield(state_, -2, "headers");
|
||||||
|
|
||||||
|
if (lua_pcall(state_, 1, 1, 0) != LUA_OK) {
|
||||||
|
providers_.log->write(LogLevel::Error, lua_tostring(state_, -1)
|
||||||
|
? lua_tostring(state_, -1)
|
||||||
|
: "onRequest failed");
|
||||||
|
lua_pop(state_, 1);
|
||||||
|
exchange.status = 500;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!lua_istable(state_, -1)) {
|
||||||
|
lua_pop(state_, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
readOptionalString(state_, "streamTo", exchange.streamTo);
|
||||||
|
lua_getfield(state_, -1, "done");
|
||||||
|
if (lua_isfunction(state_, -1)) {
|
||||||
|
luaL_unref(state_, LUA_REGISTRYINDEX, doneRef_);
|
||||||
|
doneRef_ = luaL_ref(state_, LUA_REGISTRYINDEX);
|
||||||
|
exchange.deferred = true;
|
||||||
|
} else {
|
||||||
|
lua_pop(state_, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exchange.deferred) {
|
||||||
|
// The reply comes from done() alone: half from each would leave which
|
||||||
|
// status won to a precedence rule nobody should have to remember.
|
||||||
|
HttpReply declared;
|
||||||
|
readReply(declared);
|
||||||
|
if (declared.status != 200 || !declared.body.empty() ||
|
||||||
|
!declared.streamFrom.empty() || !declared.headers.empty()) {
|
||||||
|
providers_.log->write(LogLevel::Error,
|
||||||
|
"onRequest: done cannot be combined with a reply");
|
||||||
|
exchange.status = 500;
|
||||||
|
exchange.deferred = false;
|
||||||
|
exchange.streamTo.clear();
|
||||||
|
luaL_unref(state_, LUA_REGISTRYINDEX, doneRef_);
|
||||||
|
doneRef_ = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
readReply(exchange);
|
||||||
|
}
|
||||||
|
lua_pop(state_, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Runtime::handleStreamed(const HttpStreamResult& result, HttpReply& reply) {
|
||||||
|
reply.status = 404;
|
||||||
|
if (!state_ || doneRef_ == 0)
|
||||||
|
return;
|
||||||
|
const Batch batch(*this);
|
||||||
|
|
||||||
|
const int callbackRef = doneRef_;
|
||||||
|
doneRef_ = 0;
|
||||||
|
lua_rawgeti(state_, LUA_REGISTRYINDEX, callbackRef);
|
||||||
|
lua_createtable(state_, 0, 3);
|
||||||
|
lua_pushboolean(state_, result.ok);
|
||||||
|
lua_setfield(state_, -2, "ok");
|
||||||
|
lua_pushinteger(state_, result.bytes);
|
||||||
|
lua_setfield(state_, -2, "bytes");
|
||||||
|
lua_pushlstring(state_, result.path.data(), result.path.size());
|
||||||
|
lua_setfield(state_, -2, "path");
|
||||||
|
|
||||||
|
if (lua_pcall(state_, 1, 1, 0) != LUA_OK) {
|
||||||
|
providers_.log->write(LogLevel::Error, lua_tostring(state_, -1)
|
||||||
|
? lua_tostring(state_, -1)
|
||||||
|
: "done failed");
|
||||||
|
reply.status = 500;
|
||||||
|
} else if (lua_istable(state_, -1)) {
|
||||||
|
readReply(reply);
|
||||||
|
}
|
||||||
|
lua_pop(state_, 1);
|
||||||
|
luaL_unref(state_, LUA_REGISTRYINDEX, callbackRef);
|
||||||
|
}
|
||||||
|
|
||||||
void Runtime::cancelAllTimers() {
|
void Runtime::cancelAllTimers() {
|
||||||
for (std::map<TimerId, Timer>::iterator it = timers_.begin();
|
for (std::map<TimerId, Timer>::iterator it = timers_.begin();
|
||||||
it != timers_.end(); ++it) {
|
it != timers_.end(); ++it) {
|
||||||
@@ -275,14 +478,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 +488,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 +498,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) {
|
||||||
|
|||||||
Vendored
+20
@@ -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.
|
||||||
Vendored
+211
@@ -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:
|
||||||
|
*/
|
||||||
Vendored
+32
@@ -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:
|
||||||
|
*/
|
||||||
Vendored
+1827
File diff suppressed because it is too large
Load Diff
Vendored
+199
@@ -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:
|
||||||
|
*/
|
||||||
Vendored
+157
@@ -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:
|
||||||
|
*/
|
||||||
@@ -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++; }
|
||||||
@@ -236,6 +239,40 @@ struct Http : HttpProvider {
|
|||||||
bytesWritten = options.maxBytes;
|
bytesWritten = options.maxBytes;
|
||||||
return Status::success();
|
return Status::success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HttpServe served;
|
||||||
|
HttpHandler* handler = nullptr;
|
||||||
|
bool stopped = false;
|
||||||
|
|
||||||
|
Status serve(const HttpServe& options, HttpHandler* nextHandler) override {
|
||||||
|
served = options;
|
||||||
|
handler = nextHandler;
|
||||||
|
stopped = false;
|
||||||
|
return Status::success();
|
||||||
|
}
|
||||||
|
void stopServing() override {
|
||||||
|
handler = nullptr;
|
||||||
|
stopped = true;
|
||||||
|
}
|
||||||
|
void poll() override {}
|
||||||
|
|
||||||
|
// Drives one exchange the way the firmware's request handler does: decide
|
||||||
|
// from the headers, stream the body, then answer.
|
||||||
|
HttpReply exchange(const HttpServerRequest& request, int32_t bodyBytes,
|
||||||
|
bool writeOk, std::string& streamedTo) {
|
||||||
|
HttpExchange decided;
|
||||||
|
handler->handleRequest(request, decided);
|
||||||
|
streamedTo = decided.streamTo;
|
||||||
|
if (!decided.deferred)
|
||||||
|
return decided;
|
||||||
|
HttpStreamResult result;
|
||||||
|
result.ok = writeOk;
|
||||||
|
result.bytes = bodyBytes;
|
||||||
|
result.path = decided.streamTo;
|
||||||
|
HttpReply reply;
|
||||||
|
handler->handleStreamed(result, reply);
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Timer : TimerProvider {
|
struct Timer : TimerProvider {
|
||||||
@@ -273,9 +310,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 +321,24 @@ 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; }
|
||||||
|
bool nextObservation(const std::string* after, BleObservation& out) override {
|
||||||
|
if (!after) {
|
||||||
|
out = {"aa:bb", "tag", -60, "payload", 1234};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (*after == "aa:bb") {
|
||||||
|
out = {"cc:dd", "tag 2", -70, "payload 2", 2345};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
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 +403,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 +416,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;
|
||||||
|
|||||||
+341
-128
@@ -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,76 @@ 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})");
|
||||||
|
|
||||||
|
// The server decides the exchange from the headers, before a byte of the
|
||||||
|
// body is read: that is the only point at which the destination can be named.
|
||||||
|
run(state,
|
||||||
|
"http.serve{root = '/www', hostname = 'slate32', onRequest = "
|
||||||
|
"function(req)\n"
|
||||||
|
" if req.path == '/upload' then\n"
|
||||||
|
" if req.length > 8 then return {status = 413} end\n"
|
||||||
|
" return {streamTo = '/data/' .. req.filename, done = function(r)\n"
|
||||||
|
" return {status = r.ok and 201 or 507, body = r.path .. ':' .. "
|
||||||
|
"r.bytes}\n"
|
||||||
|
" end}\n"
|
||||||
|
" end\n"
|
||||||
|
" if req.path == '/export' then\n"
|
||||||
|
" return {streamFrom = '/data/out.csv', headers = {['Content-Type'] "
|
||||||
|
"= 'text/csv'}}\n"
|
||||||
|
" end\n"
|
||||||
|
"end}");
|
||||||
|
assert(bench.http.served.root == "/www" && bench.http.served.port == 80);
|
||||||
|
assert(bench.http.served.hostname == "slate32");
|
||||||
|
|
||||||
|
esp32lua::HttpServerRequest upload;
|
||||||
|
upload.method = "PUT";
|
||||||
|
upload.path = "/upload";
|
||||||
|
upload.length = 4;
|
||||||
|
upload.filename = "note.txt";
|
||||||
|
std::string streamedTo;
|
||||||
|
esp32lua::HttpReply reply = bench.http.exchange(upload, 4, true, streamedTo);
|
||||||
|
assert(streamedTo == "/data/note.txt");
|
||||||
|
assert(reply.status == 201 && reply.body == "/data/note.txt:4");
|
||||||
|
|
||||||
|
// A failed write is done()'s to answer; the bytes it was promised never came.
|
||||||
|
reply = bench.http.exchange(upload, 2, false, streamedTo);
|
||||||
|
assert(reply.status == 507);
|
||||||
|
|
||||||
|
// Refused from Content-Length alone, so nothing is streamed anywhere.
|
||||||
|
upload.length = 99;
|
||||||
|
reply = bench.http.exchange(upload, 0, true, streamedTo);
|
||||||
|
assert(reply.status == 413 && streamedTo.empty());
|
||||||
|
|
||||||
|
esp32lua::HttpServerRequest download;
|
||||||
|
download.method = "GET";
|
||||||
|
download.path = "/export";
|
||||||
|
reply = bench.http.exchange(download, 0, true, streamedTo);
|
||||||
|
assert(reply.status == 200 && reply.streamFrom == "/data/out.csv");
|
||||||
|
assert(reply.headers.size() == 1 && reply.headers[0].value == "text/csv");
|
||||||
|
|
||||||
|
download.path = "/nope";
|
||||||
|
assert(bench.http.exchange(download, 0, true, streamedTo).status == 404);
|
||||||
|
|
||||||
|
run(state, "http.stopServing()");
|
||||||
|
assert(bench.http.stopped);
|
||||||
|
|
||||||
|
// 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"
|
||||||
@@ -96,13 +170,36 @@ int main() {
|
|||||||
"assert(wifi.connect())");
|
"assert(wifi.connect())");
|
||||||
assert(bench.wifi.savedReconnect);
|
assert(bench.wifi.savedReconnect);
|
||||||
|
|
||||||
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"
|
"observations = assert(ble.observe({ services = { '181A' } }))\n"
|
||||||
|
"assert(ble.isObserving())\n"
|
||||||
|
"local function checkObservations()\n"
|
||||||
|
" local count = 0\n"
|
||||||
|
" for address, name, rssi, payload, lastSeenMs in observations do\n"
|
||||||
|
" count = count + 1\n"
|
||||||
|
" if address == 'aa:bb' then\n"
|
||||||
|
" assert(name == 'tag' and rssi == -60)\n"
|
||||||
|
" assert(payload == 'payload' and lastSeenMs == 1234)\n"
|
||||||
|
" else\n"
|
||||||
|
" assert(address == 'cc:dd' and name == 'tag 2' and rssi == -70)\n"
|
||||||
|
" assert(payload == 'payload 2' and lastSeenMs == 2345)\n"
|
||||||
|
" end\n"
|
||||||
|
" end\n"
|
||||||
|
" assert(count == 2)\n"
|
||||||
|
"end\n"
|
||||||
|
"checkObservations()\n"
|
||||||
|
"checkObservations()\n"
|
||||||
|
"local duplicate, err = ble.observe()\n"
|
||||||
|
"assert(duplicate == nil and err == 'already observing')\n"
|
||||||
|
"ble.unobserve()\n"
|
||||||
|
"assert(not ble.isObserving())\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 +217,125 @@ 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, {w = 'fill', h = 'fill', "
|
||||||
"pad = 4, gap = 2})\n"
|
"pad = 4, gap = 2}, 'box')\n"
|
||||||
"local label = node.create(root, {type = 'text', label = 'Hello'})\n"
|
"local label = tree.create(root, {}, 'text', false, 'Hello')\n"
|
||||||
"local button = node.create(root, {type = 'button', h = 40, interactive "
|
"local button = tree.create(root, {h = 40}, 'button', true)\n"
|
||||||
"= true})\n"
|
"tree.setStyle(root, {background = 0xFFFFFF, fill = 0xFFFFFF, color = 0, "
|
||||||
"node.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,
|
||||||
"assert(node.focusFirst(root) == button)\n"
|
// not nil.
|
||||||
"assert(node.getFocus() == button)\n"
|
"assert(tree.hit(root, 10, 200) == root)\n"
|
||||||
"assert(node.moveFocus(root, 'up') == button)\n"
|
"assert(tree.focusFirst(root) == button)\n"
|
||||||
"node.setPressed(button, true)\n"
|
"assert(tree.getFocus() == button)\n"
|
||||||
"assert(node.isPressed(button))\n"
|
"assert(tree.moveFocus(root, 'up') == button)\n"
|
||||||
"assert(node.getCount() == 3 and node.getFootprint() > 0)\n"
|
"tree.setPressed(button, true)\n"
|
||||||
"node.draw(root)\n"
|
"assert(tree.isPressed(button))\n"
|
||||||
"node.dropScratch()");
|
"assert(tree.getCount() == 3 and tree.getFootprint() > 0)\n"
|
||||||
|
"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, {}, '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, {w = 'fill', h = "
|
||||||
"'fill'})\n"
|
"'fill'}, 'custom')\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, {w = 'fill', h = 'fill',\n"
|
||||||
".. ':' .. tostring(a) end end\n"
|
" scrollX = true, scrollY = true}, 'box')\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, {w = 400, h = "
|
||||||
"on_touch_down = note('down')\n"
|
"100}, 'box', 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 "
|
||||||
assert(bench.gui.commits == 6);
|
"hit')\n"
|
||||||
|
// 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, {w = 'fill', h = 'fill', scrollY = true}, "
|
||||||
|
"'box')\n"
|
||||||
|
"local child = tree.create(bare, {w = 100, h = 900}, 'box')\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, {scrollY = "
|
||||||
|
"true}, 'box')\n"
|
||||||
|
"tree.create(l, {w = 10, h = 10}, 'box')\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 +344,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,71 +413,102 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
run(state, "observations = assert(ble.observe())");
|
||||||
|
assert(bench.ble.observing);
|
||||||
|
runtime.close();
|
||||||
|
assert(!bench.ble.observing);
|
||||||
|
|
||||||
// Missing core providers leave no Lua state behind.
|
// Missing core providers leave no Lua state behind.
|
||||||
esp32lua::Providers incomplete = bench.providers();
|
esp32lua::Providers incomplete = bench.providers();
|
||||||
incomplete.fs = nullptr;
|
incomplete.fs = nullptr;
|
||||||
|
|||||||
+8
-4
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user