Compare commits
16 Commits
main
..
app-tables
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.
|
||||
|
||||
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/`.
|
||||
Apps are fully trusted; keep permissions and sandboxing out of scope.
|
||||
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
|
||||
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
|
||||
and reserve `bt` for a future Classic Bluetooth contract.
|
||||
|
||||
-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++
|
||||
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_OBJECTS := $(patsubst native/src/vendor/lua/%.c,_build/lua/%.o,$(LUA_C))
|
||||
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
|
||||
RUNTIME_TEST := _build/runtime-test
|
||||
LUAC32 := _build/luac32
|
||||
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
|
||||
|
||||
@@ -46,9 +50,9 @@ test: $(LUA_SMOKE) $(RUNTIME_TEST) $(LUAC32)
|
||||
$(LUA_SMOKE): native/test/lua_smoke.c $(LUA_LIBRARY)
|
||||
@$(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 \
|
||||
$(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)
|
||||
@$(AR) rcs $@ $^
|
||||
@@ -59,3 +63,8 @@ $(LUAC32): tools/dump.c $(LUA_LIBRARY)
|
||||
_build/lua/%.o: native/src/vendor/lua/%.c
|
||||
@mkdir -p $(@D)
|
||||
@$(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`.
|
||||
|
||||
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
|
||||
the current firmwares. Runtime bindings remain authoritative until this repository is wired into
|
||||
their tests.
|
||||
runtime live under `native/`. Runtime bindings remain authoritative until this repository is wired
|
||||
into their tests.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -26,10 +25,14 @@ native/
|
||||
```
|
||||
|
||||
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
|
||||
compose, so a device may expose both `touch` and `buttons`. Display technology is not a feature:
|
||||
an e-ink `GuiProvider` flattens a gradient the way `gui.color()` quantizes to grayscale, and the
|
||||
firmware owns publication and waveform policy on every panel.
|
||||
optional features; claiming one guarantees every API and behavior in its matching file or
|
||||
directory. Features compose, so a device may expose both `touch` and `buttons`. A panel is the
|
||||
`screen` feature -- the `screen` and `tree` namespaces, including the saved rotation and theme --
|
||||
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
|
||||
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,
|
||||
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/`
|
||||
is the opposite -- real modules that ship to the SD card, so composition like `ui.lua` and
|
||||
`hints.lua` changes without a reflash.
|
||||
@@ -47,44 +56,48 @@ provider is how `sys.hasFeature()` answers false, and its namespace additions ar
|
||||
registered.
|
||||
|
||||
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.
|
||||
Every app may use `require`; modules resolve from its app directory and `/.lua/lib`. This repository
|
||||
owns portable shared modules such as `ui.lua`; firmware-specific modules stay with their firmware.
|
||||
Every app may use `require`; the entry file points `package.path` wherever it keeps apps and
|
||||
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
|
||||
/.lua/
|
||||
main.lua the entry file a firmware boots
|
||||
apps/<AppId>/main.lua
|
||||
apps/<AppId>/<Subapp>/main.lua
|
||||
data/<AppId>/
|
||||
lib/<module>.lua
|
||||
```
|
||||
|
||||
The runtime owns app loading: `Runtime::startApp()` opens a fresh `lua_State`, points `require` at
|
||||
the app directory and `/.lua/lib`, runs the chunk, and calls `init(arg)`. `sys.launch`/`replace`/
|
||||
`back` only record intent, because swapping the state inside a callback would free the VM that is
|
||||
still executing; the firmware calls `applyPendingNavigation()` between batches. History, the
|
||||
launcher fallback, app identity, and `sys.hasFeature()` all live there too, which is why
|
||||
The runtime owns the teardown and nothing above it. `sys.startApp(path, args)` closes the
|
||||
`lua_State`, opens a fresh one, loads a path, and calls `start(args)` on the table it returns.
|
||||
The arguments cross as JSON, because the table they came from dies with the state that built it;
|
||||
encoding happens while that state still lives, so an argument JSON cannot carry raises at the call
|
||||
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`.
|
||||
|
||||
`sys.launch("Settings/Calibration")` resolves the nested `main.lua`; only top-level apps appear in
|
||||
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,
|
||||
`draw(deltaMs)` is an optional frame loop called once after `start()` and then at most 30 FPS,
|
||||
best effort. The host passes monotonic elapsed milliseconds (`0` on the first frame). Timers take
|
||||
Lua callbacks and return cancellation handles.
|
||||
|
||||
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
|
||||
`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
|
||||
its provider is a wiring bug and says so.
|
||||
|
||||
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.
|
||||
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`.
|
||||
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
|
||||
|
||||
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`,
|
||||
`custom`, and `confirm`. A screen accepts both touch and physical-button input.
|
||||
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@
|
||||
"includeDir": "native/include",
|
||||
"flags": [
|
||||
"-DLUA_32BITS",
|
||||
"-I native/src/vendor/lua"
|
||||
"-I native/src/vendor/lua",
|
||||
"-I native/src/vendor/cjson"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+23
-6
@@ -2,10 +2,16 @@
|
||||
|
||||
-- Generated from native/src/bindings/core/ble.cpp. Do not edit.
|
||||
|
||||
---@class BleDevice
|
||||
---@field name string
|
||||
---@class BleObservation
|
||||
---@field address string
|
||||
---@field name string
|
||||
---@field rssi integer
|
||||
---@field payload string Raw advertisement bytes.
|
||||
---@field lastSeenMs integer sys.getMillis() at last sighting.
|
||||
|
||||
---@class BleFilter
|
||||
---@field services? string[] Service-data UUIDs to keep.
|
||||
---@field manufacturers? integer[] Manufacturer ids to keep.
|
||||
|
||||
---@class BleLib
|
||||
ble = {}
|
||||
@@ -23,11 +29,22 @@ function ble.deinit() end
|
||||
---@return boolean
|
||||
function ble.isInitialized() end
|
||||
|
||||
---Scans for advertising devices.
|
||||
---@param durationMs? integer Defaults to 3000.
|
||||
---@return BleDevice[]? devices
|
||||
---Starts passively observing advertisements, coalesced per device.
|
||||
---@param filter? BleFilter Keep only matching adverts; nil keeps all.
|
||||
---@return true? ok
|
||||
---@return string? error
|
||||
function ble.scan(durationMs) end
|
||||
function ble.observe(filter) end
|
||||
|
||||
---Stops observing and clears the snapshot.
|
||||
function ble.unobserve() end
|
||||
|
||||
---Whether advertisement observation is running.
|
||||
---@return boolean
|
||||
function ble.isObserving() end
|
||||
|
||||
---The current snapshot of observed devices.
|
||||
---@return BleObservation[] devices
|
||||
function ble.observed() end
|
||||
|
||||
---Connects to a peripheral.
|
||||
---@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
|
||||
@@ -2,21 +2,17 @@
|
||||
|
||||
-- Generated from native/src/runtime/runtime.cpp. Do not edit.
|
||||
|
||||
-- Runtime layout:
|
||||
-- /.lua/apps/<AppId>/main.lua application entry point
|
||||
-- /.lua/apps/<AppId>/<Subapp>/main.lua nested route, omitted from the launcher
|
||||
-- /.lua/data/<AppId>/ persistent app data, preserved across updates
|
||||
-- /.lua/lib/<module>.lua shared require() modules
|
||||
-- require() also searches the running application's directory
|
||||
-- The firmware loads the path it was booted with into every fresh state and calls
|
||||
-- these on the table it returns. Where apps live, what surrounds them and which of
|
||||
-- these an app itself sees are all that file's to decide, which is why an app
|
||||
-- composes the classes for the features it handles:
|
||||
--
|
||||
-- ---@class PaintApp : App, TouchHandlers
|
||||
--
|
||||
-- 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.
|
||||
-- Timer callbacks are registered directly with timer.after/every.
|
||||
|
||||
---Required. Runs once before the first draw; failing here stops the app.
|
||||
---@param arg? string The string passed to sys.launch or sys.replace.
|
||||
function init(arg) end
|
||||
|
||||
---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
|
||||
---@class App
|
||||
---@field start fun(args?: table) Required. Mounts whatever the arguments describe; failing here leaves no app running.
|
||||
---@field draw? fun(deltaMs: integer) Optional frame loop, called once after start and then at most 30 FPS, best effort.
|
||||
|
||||
@@ -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.
|
||||
|
||||
---@alias Feature "touch"|"buttons"
|
||||
---@alias Feature "screen"|"touch"|"buttons"
|
||||
|
||||
---@class SysLib
|
||||
sys = {}
|
||||
@@ -20,34 +20,10 @@ function sys.hasFeature(feature) end
|
||||
---@return integer
|
||||
function sys.getMillis() end
|
||||
|
||||
---Returns the immutable first path component of the running app.
|
||||
---@return string
|
||||
function sys.getAppID() 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
|
||||
---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.
|
||||
---@param path string Absolute path to the Lua file to load; traversal is rejected.
|
||||
---@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 heap statistics.
|
||||
---@return integer freeBytes
|
||||
@@ -58,3 +34,13 @@ function sys.getMemory() end
|
||||
---Whether network time synchronization has completed.
|
||||
---@return boolean
|
||||
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"
|
||||
|
||||
-- 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
|
||||
input = input or {}
|
||||
---@class ButtonsLib
|
||||
buttons = {}
|
||||
|
||||
---Returns the roles this device reports, so an app can label only the actions it has.
|
||||
---@return Button[]
|
||||
function input.getButtons() end
|
||||
function buttons.getAll() end
|
||||
|
||||
---Whether any button is held.
|
||||
---@return boolean
|
||||
function input.isAnyPressed() end
|
||||
function buttons.isAnyPressed() end
|
||||
|
||||
---Whether a button is held.
|
||||
---@param button Button
|
||||
---@return boolean
|
||||
function input.isPressed(button) end
|
||||
function buttons.isPressed(button) end
|
||||
|
||||
---Whether a button went down since the last poll.
|
||||
---@param button Button
|
||||
---@return boolean
|
||||
function input.wasPressed(button) end
|
||||
function buttons.wasPressed(button) end
|
||||
|
||||
---Whether a button came up since the last poll.
|
||||
---@param button Button
|
||||
---@return boolean
|
||||
function input.wasReleased(button) end
|
||||
function buttons.wasReleased(button) end
|
||||
|
||||
---Fired when a button goes down.
|
||||
---@param button Button
|
||||
function on_button_down(button) end
|
||||
-- What an app implements to see buttons, composed into its own class:
|
||||
--
|
||||
-- ---@class MenuApp : App, ButtonHandlers
|
||||
|
||||
---Fired when a button comes up.
|
||||
---@param button Button
|
||||
function on_button_up(button) end
|
||||
|
||||
---Tap alias, fired on release like a click, after on_button_up.
|
||||
---@param button Button
|
||||
function on_button(button) end
|
||||
---@class ButtonHandlers
|
||||
---@field onButtonDown? fun(button: Button) Fired when a button goes down.
|
||||
---@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.
|
||||
|
||||
@@ -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
|
||||
|
||||
-- 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 NodeType "box"|"text"|"button"|"custom"
|
||||
@@ -16,46 +16,45 @@
|
||||
---@field justify? "start"|"center"|"end"|"between"
|
||||
---@field row? boolean
|
||||
---@field at? table Absolute-position fields.
|
||||
---@field capture? boolean
|
||||
---@field interactive? boolean
|
||||
---@field label? string
|
||||
---@field font? GuiFont
|
||||
---@field font? ScreenFont
|
||||
|
||||
---@class NodeStyle
|
||||
---@field color? GuiColor
|
||||
---@field background? GuiColor Background offered to descendants.
|
||||
---@field fill? GuiColor Surface painted by a box.
|
||||
---@field border? GuiColor
|
||||
---@field face? GuiColor Default button surface.
|
||||
---@field pressedFace? GuiColor Pressed button surface.
|
||||
---@field pressedColor? GuiColor Pressed button text.
|
||||
---@field focusColor? GuiColor Distinct outline for directional focus.
|
||||
---@field color? ScreenColor
|
||||
---@field background? ScreenColor Background offered to descendants.
|
||||
---@field fill? ScreenColor Surface painted by a box.
|
||||
---@field border? ScreenColor
|
||||
---@field face? ScreenColor Default button surface.
|
||||
---@field pressedFace? ScreenColor Pressed button surface.
|
||||
---@field pressedColor? ScreenColor Pressed button text.
|
||||
---@field focusColor? ScreenColor Distinct outline for directional focus.
|
||||
---@field radius? integer
|
||||
---@field font? GuiFont
|
||||
---@field textStyle? GuiTextStyle
|
||||
---@field font? ScreenFont
|
||||
---@field textStyle? ScreenTextStyle
|
||||
|
||||
---@class NodeLib
|
||||
node = {}
|
||||
---@class TreeLib
|
||||
tree = {}
|
||||
|
||||
---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.
|
||||
---@param parent? NodeId Nil creates a root.
|
||||
---@param spec NodeSpec
|
||||
---@return NodeId
|
||||
function node.create(parent, spec) end
|
||||
function tree.create(parent, spec) end
|
||||
|
||||
---Adopts an existing root as a child.
|
||||
---@param parent NodeId
|
||||
---@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.
|
||||
---@param id NodeId
|
||||
---@param w? 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.
|
||||
---@param root NodeId
|
||||
@@ -65,17 +64,17 @@ function node.setSize(id, w, h) end
|
||||
---@param h integer
|
||||
---@return true? ok
|
||||
---@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.
|
||||
function node.dropScratch() end
|
||||
function tree.dropScratch() end
|
||||
|
||||
---Returns the deepest interactive node under a point.
|
||||
---@param root NodeId
|
||||
---@param x integer
|
||||
---@param y integer
|
||||
---@return NodeId?
|
||||
function node.hit(root, x, y) end
|
||||
function tree.hit(root, x, y) end
|
||||
|
||||
---Returns a node's placed rectangle.
|
||||
---@param id NodeId
|
||||
@@ -83,73 +82,89 @@ function node.hit(root, x, y) end
|
||||
---@return integer y
|
||||
---@return integer w
|
||||
---@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.
|
||||
---@param id NodeId
|
||||
---@param text string
|
||||
function node.setLabel(id, text) end
|
||||
function tree.setLabel(id, text) end
|
||||
|
||||
---Returns a node's text.
|
||||
---@param id NodeId
|
||||
---@return string?
|
||||
function node.getLabel(id) end
|
||||
function tree.getLabel(id) end
|
||||
|
||||
---Returns a node's parent.
|
||||
---@param id NodeId
|
||||
---@return NodeId?
|
||||
function node.getParent(id) end
|
||||
function tree.getParent(id) end
|
||||
|
||||
---Sets the style roles a subtree inherits.
|
||||
---@param id NodeId
|
||||
---@param style NodeStyle
|
||||
function node.setStyle(id, style) end
|
||||
function tree.setStyle(id, style) end
|
||||
|
||||
---Marks a node for repaint.
|
||||
---@param id NodeId
|
||||
function node.invalidate(id) end
|
||||
function tree.invalidate(id) end
|
||||
|
||||
---Sets a node's pressed state.
|
||||
---@param id NodeId
|
||||
---@param pressed boolean
|
||||
function node.setPressed(id, pressed) end
|
||||
function tree.setPressed(id, pressed) end
|
||||
|
||||
---Whether a node is pressed.
|
||||
---@param id NodeId
|
||||
---@return boolean
|
||||
function node.isPressed(id) end
|
||||
function tree.isPressed(id) end
|
||||
|
||||
---Focuses the first interactive node in layout order.
|
||||
---@param root NodeId
|
||||
---@return NodeId? focused
|
||||
function node.focusFirst(root) end
|
||||
function tree.focusFirst(root) end
|
||||
|
||||
---Changes focus and invalidates the previously and newly focused nodes.
|
||||
---@param id? NodeId Nil clears focus.
|
||||
function node.setFocus(id) end
|
||||
function tree.setFocus(id) end
|
||||
|
||||
---Returns the focused node.
|
||||
---@return NodeId?
|
||||
function node.getFocus() end
|
||||
function tree.getFocus() end
|
||||
|
||||
---Moves to the nearest interactive node in the requested direction without wrapping.
|
||||
---@param root NodeId
|
||||
---@param direction NodeDirection
|
||||
---@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.
|
||||
---@param painter fun(id: NodeId, x: integer, y: integer, w: integer, h: integer)
|
||||
function node.setPainter(painter) end
|
||||
---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, clipX: integer, clipY: integer, clipW: integer, clipH: integer)
|
||||
function tree.setPainter(painter) end
|
||||
|
||||
---Paints dirty nodes; the firmware owns publication to the physical display.
|
||||
---@param root NodeId
|
||||
function node.draw(root) end
|
||||
function tree.draw(root) end
|
||||
|
||||
---Returns the number of nodes in the tree.
|
||||
---@return integer
|
||||
function node.getCount() end
|
||||
function tree.getCount() end
|
||||
|
||||
---Returns the tree's memory use.
|
||||
---@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.
|
||||
|
||||
---@class SettingsLib
|
||||
settings = settings or {}
|
||||
---@class TouchLib
|
||||
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.
|
||||
---@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.
|
||||
---@return true? ok
|
||||
---@return string? error
|
||||
function settings.setCalibration(x0, y0, x1, y1) end
|
||||
function touch.setCalibration(x0, y0, x1, y1) end
|
||||
|
||||
---@class InputLib
|
||||
input = input or {}
|
||||
-- What an app implements to see raw touch, composed into its own class:
|
||||
--
|
||||
-- ---@class PaintApp : App, TouchHandlers
|
||||
|
||||
---Returns the calibrated touch point, or nothing when the panel is not touched.
|
||||
---@return integer? x
|
||||
---@return integer? y
|
||||
function input.getTouch() end
|
||||
|
||||
---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
|
||||
---@class TouchHandlers
|
||||
---@field onTouchDown? fun(x: integer, y: integer) Fired when the finger lands.
|
||||
---@field onTouchMove? fun(x: integer, y: integer) Fired when the finger moves while down, after the firmware's jitter filter.
|
||||
---@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.
|
||||
|
||||
@@ -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 roles = {}
|
||||
if input and input.getButtons then
|
||||
for _, role in ipairs(input.getButtons()) do
|
||||
if buttons and buttons.getAll then
|
||||
for _, role in ipairs(buttons.getAll()) do
|
||||
roles[role] = true
|
||||
end
|
||||
end
|
||||
@@ -21,11 +21,11 @@ end
|
||||
---@param options table|nil `y`, `font`, `color`, and `background` overrides.
|
||||
function hints.draw(actions, options)
|
||||
options = options or {}
|
||||
local font = options.font or gui.FONT_SMALL
|
||||
local color = options.color or gui.color(0, 0, 0)
|
||||
local background = options.background or gui.color(255, 255, 255)
|
||||
local height = gui.getFontHeight(font) + 6
|
||||
local y = options.y or (gui.getHeight() - height)
|
||||
local font = options.font or screen.FONT_SMALL
|
||||
local color = options.color or screen.color(0, 0, 0)
|
||||
local background = options.background or screen.color(255, 255, 255)
|
||||
local height = screen.getFontHeight(font) + 6
|
||||
local y = options.y or (screen.getHeight() - height)
|
||||
|
||||
local roles = available()
|
||||
local labels = {}
|
||||
@@ -35,15 +35,15 @@ function hints.draw(actions, options)
|
||||
end
|
||||
end
|
||||
|
||||
gui.fillRect(0, y, gui.getWidth(), height, background)
|
||||
screen.fillRect(0, y, screen.getWidth(), height, background)
|
||||
if #labels == 0 then
|
||||
return height
|
||||
end
|
||||
|
||||
local slot = gui.getWidth() // #labels
|
||||
local slot = screen.getWidth() // #labels
|
||||
for index, label in ipairs(labels) do
|
||||
local left = slot * (index - 1) + (slot - gui.getTextWidth(font, label)) // 2
|
||||
gui.drawText(font, left, y + 3, label, color, gui.STYLE_NORMAL, background)
|
||||
local left = slot * (index - 1) + (slot - screen.getTextWidth(font, label)) // 2
|
||||
screen.drawText(font, left, y + 3, label, color, screen.STYLE_NORMAL, background)
|
||||
end
|
||||
return height
|
||||
end
|
||||
|
||||
+301
-103
@@ -12,7 +12,6 @@ local ui = {}
|
||||
---@field justify? "start"|"center"|"end"|"between"
|
||||
---@field row? boolean
|
||||
---@field at? table
|
||||
---@field capture? boolean
|
||||
---@field label? string
|
||||
---@field font? GuiFont
|
||||
---@field style? GuiTextStyle
|
||||
@@ -27,8 +26,10 @@ local ui = {}
|
||||
---@field on_enter? UiHandler
|
||||
---@field on_exit? 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 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
|
||||
---@field title string
|
||||
@@ -42,7 +43,6 @@ local ui = {}
|
||||
---@field on_cancel? UiHandler
|
||||
---@field on_outside? UiHandler
|
||||
|
||||
local THEME_PATH = "/.lua/theme"
|
||||
local THEMES = {
|
||||
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 },
|
||||
@@ -55,9 +55,29 @@ local enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {}
|
||||
local pressStyles = {}
|
||||
local laidOut = false
|
||||
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
|
||||
|
||||
-- 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 out = {}
|
||||
for i = 1, 3 do
|
||||
@@ -67,7 +87,7 @@ local function mix(a, b, amount)
|
||||
end
|
||||
|
||||
local function color(rgb)
|
||||
return gui.color(rgb[1], rgb[2], rgb[3])
|
||||
return screen.color(rgb[1], rgb[2], rgb[3])
|
||||
end
|
||||
|
||||
local function palette(seed)
|
||||
@@ -113,31 +133,33 @@ function ui.setTheme(name)
|
||||
if not THEMES[name] then
|
||||
return nil, "Unknown theme"
|
||||
end
|
||||
local ok, err = fs.writeFile(THEME_PATH, name)
|
||||
local ok, err = screen.setTheme(name)
|
||||
if not ok then
|
||||
return nil, err
|
||||
end
|
||||
loadTheme(name)
|
||||
if activeScreen then
|
||||
applyPalette(activeScreen.root)
|
||||
gui.clear(ui.theme.background)
|
||||
node.invalidate(activeScreen.root)
|
||||
if root then
|
||||
applyPalette(root)
|
||||
screen.clear(ui.theme.background)
|
||||
tree.invalidate(root)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local savedTheme = fs.readFile(THEME_PATH, 32)
|
||||
loadTheme(savedTheme and savedTheme:match "^%s*(.-)%s*$" or "light")
|
||||
loadTheme(screen.getTheme())
|
||||
|
||||
local function clearState()
|
||||
enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {}
|
||||
pressStyles = {}
|
||||
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]
|
||||
if painter then
|
||||
painter(id, x, y, w, h)
|
||||
painter(id, x, y, w, h, clipX, clipY, clipW, clipH)
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -165,14 +187,16 @@ local function applyStyle(id, spec)
|
||||
style.background, style.fill, hasStyle = spec.background, spec.background, true
|
||||
end
|
||||
if hasStyle then
|
||||
node.setStyle(id, style)
|
||||
tree.setStyle(id, style)
|
||||
end
|
||||
end
|
||||
|
||||
local function build(spec, kind)
|
||||
spec = spec or {}
|
||||
-- Nodes outside a build would reset the arena under the screen already on the
|
||||
-- panel, chrome included. Rebuilding is the only way to change one.
|
||||
if laidOut then
|
||||
ui.reset()
|
||||
error("build nodes from the function ui.mount() was given, then ui.rebuild()", 3)
|
||||
end
|
||||
|
||||
local children = {}
|
||||
@@ -183,9 +207,9 @@ local function build(spec, kind)
|
||||
|
||||
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)
|
||||
local id = tree.create(nil, spec)
|
||||
for _, child in ipairs(children) do
|
||||
node.attach(id, child)
|
||||
tree.attach(id, child)
|
||||
end
|
||||
|
||||
applyStyle(id, spec)
|
||||
@@ -194,6 +218,10 @@ local function build(spec, kind)
|
||||
clickHandlers[id] = spec.on_click
|
||||
painters[id] = spec.paint
|
||||
pressStyles[id] = spec.press_style ~= false
|
||||
if spec.scrollX or spec.scrollY then
|
||||
scrollNodes[id] = { x = spec.scrollX == true, y = spec.scrollY == true }
|
||||
scrollState[id] = { sx = 0, sy = 0, vx = 0, vy = 0 }
|
||||
end
|
||||
return id
|
||||
end
|
||||
|
||||
@@ -212,13 +240,28 @@ end
|
||||
---@return integer side
|
||||
---@return integer columns
|
||||
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 byWidth = (gui.getWidth() - 2 * pad - (columns - 1) * gap) // columns
|
||||
local byHeight = (gui.getHeight() - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows
|
||||
local byWidth = (width - 2 * pad - (columns - 1) * gap) // columns
|
||||
local byHeight = (height - 2 * pad - (reserve or 0) - (rows - 1) * gap) // rows
|
||||
return math.min(byWidth, byHeight), columns
|
||||
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
|
||||
---@return NodeId
|
||||
function ui.spacer(spec)
|
||||
@@ -241,15 +284,15 @@ end
|
||||
---@return NodeId
|
||||
function ui.label(text, spec)
|
||||
spec = spec or {}
|
||||
local font, style = spec.font or gui.FONT_UI, spec.style or gui.STYLE_NORMAL
|
||||
if spec.fit and gui.getTextWidth(font, text, style) > spec.fit then
|
||||
while #text > 1 and gui.getTextWidth(font, text .. "~", style) > spec.fit do
|
||||
local font, style = spec.font or screen.FONT_UI, spec.style or screen.STYLE_NORMAL
|
||||
if spec.fit and 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)
|
||||
end
|
||||
text = text .. "~"
|
||||
end
|
||||
spec.w = gui.getTextWidth(font, text, style)
|
||||
spec.h = gui.getFontHeight(font, style)
|
||||
spec.w = screen.getTextWidth(font, text, style)
|
||||
spec.h = screen.getFontHeight(font, style)
|
||||
spec.font, spec.fit = font, nil
|
||||
return ui.text(text, spec)
|
||||
end
|
||||
@@ -264,7 +307,7 @@ function ui.button(spec)
|
||||
spec.label = nil
|
||||
local id = build(spec, "button")
|
||||
if label then
|
||||
node.create(id, { type = "text", label = label, font = font or gui.FONT_UI })
|
||||
tree.create(id, { type = "text", label = label, font = font or screen.FONT_UI })
|
||||
end
|
||||
return id
|
||||
end
|
||||
@@ -278,16 +321,39 @@ end
|
||||
---@param id NodeId
|
||||
---@param text string
|
||||
function ui.setText(id, text)
|
||||
if node.getLabel(id) == text then
|
||||
if tree.getLabel(id) == text then
|
||||
return
|
||||
end
|
||||
node.setLabel(id, text)
|
||||
node.invalidate(id)
|
||||
tree.setLabel(id, text)
|
||||
tree.invalidate(id)
|
||||
end
|
||||
|
||||
---@param id NodeId
|
||||
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
|
||||
|
||||
---@param spec UiConfirmSpec
|
||||
@@ -316,7 +382,9 @@ function ui.confirm(spec)
|
||||
at = { x = 0, y = 0 },
|
||||
w = "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",
|
||||
justify = "center",
|
||||
on_click = spec.on_outside,
|
||||
@@ -325,20 +393,18 @@ function ui.confirm(spec)
|
||||
end
|
||||
|
||||
function ui.reset()
|
||||
node.reset()
|
||||
tree.reset()
|
||||
clearState()
|
||||
laidOut = false
|
||||
activeScreen = nil
|
||||
root, responder, insideResponder, confirming = nil, nil, nil, nil
|
||||
scrollNodes, scrollState, flinging = {}, {}, {}
|
||||
panning, scrollAncestor, dragged = nil, nil, false
|
||||
end
|
||||
|
||||
---@class UiScreen
|
||||
local Screen = {}
|
||||
Screen.__index = Screen
|
||||
|
||||
applyPalette = function(root)
|
||||
-- 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.
|
||||
node.setStyle(root, {
|
||||
tree.setStyle(root, {
|
||||
color = ui.theme.color,
|
||||
background = ui.theme.background,
|
||||
face = ui.theme.face,
|
||||
@@ -346,47 +412,131 @@ applyPalette = function(root)
|
||||
pressedColor = ui.theme.pressedColor,
|
||||
focusColor = ui.theme.focusColor,
|
||||
radius = ui.theme.radius,
|
||||
font = gui.FONT_UI,
|
||||
font = screen.FONT_UI,
|
||||
})
|
||||
end
|
||||
|
||||
---@param root NodeId
|
||||
---@param style? NodeStyle
|
||||
---@return UiScreen
|
||||
function ui.screen(root, style)
|
||||
node.setSize(root, "fill", "fill")
|
||||
applyPalette(root)
|
||||
if style then
|
||||
node.setStyle(root, style)
|
||||
end
|
||||
local screen = setmetatable({ root = root }, Screen)
|
||||
activeScreen = screen
|
||||
screen:relayout()
|
||||
return screen
|
||||
---Registers the function that builds the whole tree and shows what it returns.
|
||||
---@param fn fun(): NodeId
|
||||
function ui.mount(fn)
|
||||
builder = fn
|
||||
ui.rebuild()
|
||||
end
|
||||
|
||||
function Screen:relayout()
|
||||
local ok, err = node.layout(self.root, 0, 0, gui.getWidth(), gui.getHeight())
|
||||
---Rebuilds the tree from scratch and repaints. Screens are not retained, so this
|
||||
---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
|
||||
error(err, 2)
|
||||
end
|
||||
node.dropScratch()
|
||||
tree.dropScratch()
|
||||
laidOut = true
|
||||
gui.clear(ui.theme.background)
|
||||
screen.clear(ui.theme.background)
|
||||
tree.draw(root)
|
||||
-- A build allocates a spec table per node and drops them all here, and the next thing an
|
||||
-- app does may be the one that needs a contiguous WiFi buffer. Collecting now costs a few
|
||||
-- milliseconds on a screen change nobody can see, and leaves the heap in a known state
|
||||
-- instead of one that depends on when the incremental GC last ran.
|
||||
collectgarbage()
|
||||
end
|
||||
|
||||
function Screen:draw()
|
||||
node.draw(self.root)
|
||||
-- Pushes a node's float offset into the tree, then snaps our copy back to whatever the
|
||||
-- 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
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
local function enter(id, x, y)
|
||||
if pressStyles[id] then
|
||||
node.setPressed(id, true)
|
||||
tree.setPressed(id, true)
|
||||
end
|
||||
local handler = enterHandlers[id]
|
||||
if handler then
|
||||
@@ -396,7 +546,7 @@ end
|
||||
|
||||
local function exit(id, x, y)
|
||||
if pressStyles[id] then
|
||||
node.setPressed(id, false)
|
||||
tree.setPressed(id, false)
|
||||
end
|
||||
local handler = exitHandlers[id]
|
||||
if handler then
|
||||
@@ -407,40 +557,77 @@ end
|
||||
---@param x integer
|
||||
---@param y integer
|
||||
---@return boolean handled
|
||||
function Screen:down(x, y)
|
||||
local focused = node.getFocus()
|
||||
function ui.down(x, y)
|
||||
local focused = tree.getFocus()
|
||||
if focused then
|
||||
node.setFocus(nil)
|
||||
tree.setFocus(nil)
|
||||
local handler = exitHandlers[focused]
|
||||
if handler then
|
||||
handler(focused)
|
||||
end
|
||||
end
|
||||
|
||||
local target = node.hit(self.root, x, y)
|
||||
if not target then
|
||||
local hitNode = root and tree.hit(root, x, y)
|
||||
if not hitNode then
|
||||
return false
|
||||
end
|
||||
self.captured, self.inside = target, true
|
||||
enter(target, x, y)
|
||||
return true
|
||||
responder, scrollAncestor = handlerAncestor(hitNode), scrollableAncestor(hitNode)
|
||||
insideResponder, dragged, panning = responder ~= nil, false, nil
|
||||
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
|
||||
|
||||
---@param x integer
|
||||
---@param y integer
|
||||
---@return boolean handled
|
||||
function Screen:move(x, y)
|
||||
local target = self.captured
|
||||
if not target then
|
||||
function ui.move(x, y)
|
||||
if not responder and not scrollAncestor then
|
||||
return false
|
||||
end
|
||||
local isInside = inside(target, x, y)
|
||||
if isInside ~= self.inside then
|
||||
self.inside = isInside
|
||||
if isInside then
|
||||
enter(target, x, y)
|
||||
else
|
||||
exit(target, x, y)
|
||||
if scrollAncestor then
|
||||
local flags = scrollNodes[scrollAncestor]
|
||||
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
|
||||
enter(responder, x, y)
|
||||
else
|
||||
exit(responder, x, y)
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
@@ -449,28 +636,39 @@ end
|
||||
---@param x integer
|
||||
---@param y integer
|
||||
---@return boolean handled
|
||||
function Screen:up(x, y)
|
||||
local target = self.captured
|
||||
if not target then
|
||||
function ui.up(x, y)
|
||||
if not responder and not scrollAncestor then
|
||||
return false
|
||||
end
|
||||
local wasActive = self.inside
|
||||
local releasedInside = inside(target, x, y)
|
||||
local handler = releasedInside and clickHandlers[target] or nil
|
||||
self.captured, self.inside = nil, nil
|
||||
if wasActive then
|
||||
exit(target, x, y)
|
||||
if dragged then
|
||||
-- The gesture was a pan: hand any velocity the last frames built to draw(), which coasts
|
||||
-- and clamps it. A drag that ended still carries zero velocity, so it simply stops.
|
||||
if panning then
|
||||
flinging[panning] = true
|
||||
end
|
||||
responder, insideResponder, scrollAncestor, dragged, panning = nil, nil, nil, false, nil
|
||||
return true
|
||||
end
|
||||
if handler then
|
||||
handler(target, x, y)
|
||||
local target, wasActive = responder, insideResponder
|
||||
responder, insideResponder, scrollAncestor = nil, nil, nil
|
||||
if target then
|
||||
if wasActive then
|
||||
exit(target, x, y)
|
||||
end
|
||||
if inside(target, x, y) then
|
||||
local handler = clickHandlers[target]
|
||||
if handler then
|
||||
handler(target, x, y)
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local DIRECTIONS = { up = true, down = true, left = true, right = true }
|
||||
|
||||
local function focusFirst(screen)
|
||||
local focused = node.focusFirst(screen.root)
|
||||
local function focusFirst()
|
||||
local focused = tree.focusFirst(root)
|
||||
if focused then
|
||||
local handler = enterHandlers[focused]
|
||||
if handler then
|
||||
@@ -483,7 +681,7 @@ end
|
||||
---@param name string Button name; directions and confirm are handled.
|
||||
---@param pressed boolean
|
||||
---@return boolean handled
|
||||
function Screen:button(name, pressed)
|
||||
function ui.buttonPress(name, pressed)
|
||||
if type(pressed) ~= "boolean" then
|
||||
error("button state must be boolean", 2)
|
||||
end
|
||||
@@ -492,12 +690,12 @@ function Screen:button(name, pressed)
|
||||
if not pressed then
|
||||
return true
|
||||
end
|
||||
local previous = node.getFocus()
|
||||
local previous = tree.getFocus()
|
||||
if not previous then
|
||||
focusFirst(self)
|
||||
focusFirst()
|
||||
return true
|
||||
end
|
||||
local focused = node.moveFocus(self.root, name)
|
||||
local focused = tree.moveFocus(root, name)
|
||||
if focused ~= previous then
|
||||
local leave = exitHandlers[previous]
|
||||
if leave then
|
||||
@@ -514,18 +712,18 @@ function Screen:button(name, pressed)
|
||||
if name ~= "confirm" then
|
||||
return false
|
||||
end
|
||||
local focused = node.getFocus() or focusFirst(self)
|
||||
local focused = tree.getFocus() or focusFirst()
|
||||
if not focused then
|
||||
return false
|
||||
end
|
||||
if pressed then
|
||||
node.setPressed(focused, true)
|
||||
self.confirming = focused
|
||||
tree.setPressed(focused, true)
|
||||
confirming = focused
|
||||
else
|
||||
local target = self.confirming
|
||||
self.confirming = nil
|
||||
local target = confirming
|
||||
confirming = nil
|
||||
if target then
|
||||
node.setPressed(target, false)
|
||||
tree.setPressed(target, false)
|
||||
local handler = clickHandlers[target]
|
||||
if handler then
|
||||
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 roles = { "confirm", "back", "right" }
|
||||
|
||||
gui = {
|
||||
screen = {
|
||||
FONT_SMALL = 0,
|
||||
STYLE_NORMAL = 0,
|
||||
color = function(r, g, b)
|
||||
@@ -29,8 +29,8 @@ gui = {
|
||||
end,
|
||||
}
|
||||
|
||||
input = {
|
||||
getButtons = function()
|
||||
buttons = {
|
||||
getAll = function()
|
||||
return roles
|
||||
end,
|
||||
}
|
||||
|
||||
+74
-43
@@ -17,9 +17,17 @@ fs = {
|
||||
end,
|
||||
}
|
||||
|
||||
local savedTheme = "light"
|
||||
local frameWidth, frameHeight = 320, 480
|
||||
|
||||
gui = {
|
||||
screen = {
|
||||
getTheme = function()
|
||||
return savedTheme
|
||||
end,
|
||||
setTheme = function(name)
|
||||
savedTheme = name
|
||||
return true
|
||||
end,
|
||||
FONT_SMALL = 0,
|
||||
FONT_UI = 1,
|
||||
FONT_BODY = 2,
|
||||
@@ -59,7 +67,7 @@ local function interactiveNodes()
|
||||
return result
|
||||
end
|
||||
|
||||
node = {
|
||||
tree = {
|
||||
reset = function()
|
||||
nodes, focus, buttonCount = {}, nil, 0
|
||||
end,
|
||||
@@ -84,6 +92,9 @@ node = {
|
||||
attach = function(parent, child)
|
||||
nodes[child].parent = parent
|
||||
end,
|
||||
getParent = function(id)
|
||||
return nodes[id].parent
|
||||
end,
|
||||
setSize = function() end,
|
||||
setStyle = function(id, style)
|
||||
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"
|
||||
assert(ok == nil and err == "Unknown theme")
|
||||
assert(ui.setTheme "dark" == true)
|
||||
assert(files["/.lua/theme"] == "dark" and ui.getTheme() == "dark")
|
||||
assert(savedTheme == "dark" and ui.getTheme() == "dark")
|
||||
|
||||
local events = {}
|
||||
local function handler(name)
|
||||
@@ -175,28 +186,31 @@ local function handler(name)
|
||||
end
|
||||
end
|
||||
|
||||
local first = ui.button {
|
||||
label = "one",
|
||||
on_enter = handler "enter",
|
||||
on_exit = handler "exit",
|
||||
on_click = handler "click",
|
||||
}
|
||||
local second = ui.button {
|
||||
label = "two",
|
||||
on_enter = handler "enter",
|
||||
on_exit = handler "exit",
|
||||
on_click = handler "click",
|
||||
}
|
||||
local screen = ui.screen(ui.box { row = true, first, second })
|
||||
local first, second
|
||||
ui.mount(function()
|
||||
first = ui.button {
|
||||
label = "one",
|
||||
on_enter = handler "enter",
|
||||
on_exit = handler "exit",
|
||||
on_click = handler "click",
|
||||
}
|
||||
second = ui.button {
|
||||
label = "two",
|
||||
on_enter = handler "enter",
|
||||
on_exit = handler "exit",
|
||||
on_click = handler "click",
|
||||
}
|
||||
return ui.box { row = true, first, second }
|
||||
end)
|
||||
|
||||
assert(screen:down(10, 10))
|
||||
assert(node.isPressed(first))
|
||||
assert(screen:move(95, 10))
|
||||
assert(not node.isPressed(first))
|
||||
assert(screen:move(10, 10))
|
||||
assert(node.isPressed(first))
|
||||
assert(screen:up(10, 10))
|
||||
assert(not node.isPressed(first))
|
||||
assert(ui.down(10, 10))
|
||||
assert(tree.isPressed(first))
|
||||
assert(ui.move(95, 10))
|
||||
assert(not tree.isPressed(first))
|
||||
assert(ui.move(10, 10))
|
||||
assert(tree.isPressed(first))
|
||||
assert(ui.up(10, 10))
|
||||
assert(not tree.isPressed(first))
|
||||
|
||||
local expectedTouch = { "enter", "exit", "enter", "exit", "click" }
|
||||
for index, name in ipairs(expectedTouch) do
|
||||
@@ -207,16 +221,16 @@ for index, name in ipairs(expectedTouch) do
|
||||
end
|
||||
|
||||
events = {}
|
||||
assert(screen:button("right", true))
|
||||
assert(screen:button("right", false))
|
||||
assert(node.getFocus() == first)
|
||||
assert(screen:button("right", true))
|
||||
assert(node.getFocus() == second)
|
||||
assert(screen:button("confirm", true))
|
||||
assert(node.isPressed(second))
|
||||
assert(screen:button("confirm", false))
|
||||
assert(not node.isPressed(second))
|
||||
assert(screen:button("back", true) == false)
|
||||
assert(ui.buttonPress("right", true))
|
||||
assert(ui.buttonPress("right", false))
|
||||
assert(tree.getFocus() == first)
|
||||
assert(ui.buttonPress("right", true))
|
||||
assert(tree.getFocus() == second)
|
||||
assert(ui.buttonPress("confirm", true))
|
||||
assert(tree.isPressed(second))
|
||||
assert(ui.buttonPress("confirm", false))
|
||||
assert(not tree.isPressed(second))
|
||||
assert(ui.buttonPress("back", true) == false)
|
||||
|
||||
local expectedButtons = {
|
||||
{ "enter", first },
|
||||
@@ -235,7 +249,13 @@ assert(ui.setTheme "mono" == true)
|
||||
assert(ui.getTheme() == "mono" and #invalidated == before + 1)
|
||||
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.
|
||||
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.
|
||||
ui.reset()
|
||||
local pressedCalls = {}
|
||||
node.setPressed = function(_, on)
|
||||
tree.setPressed = function(_, on)
|
||||
pressedCalls[#pressedCalls + 1] = on
|
||||
end
|
||||
local own = ui.custom { h = 20, press_style = false, on_click = function() end }
|
||||
local styled = ui.button { h = 20, label = "ok", on_click = function() end }
|
||||
local board = ui.screen(ui.box { own, styled })
|
||||
local own, styled
|
||||
ui.mount(function()
|
||||
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
|
||||
node.hit = function()
|
||||
tree.hit = function()
|
||||
return target
|
||||
end
|
||||
|
||||
target = own
|
||||
board:down(0, 0)
|
||||
board:up(0, 0)
|
||||
ui.down(0, 0)
|
||||
ui.up(0, 0)
|
||||
assert(#pressedCalls == 0, "a self-painting widget is not styled on press")
|
||||
|
||||
target = styled
|
||||
board:down(0, 0)
|
||||
ui.down(0, 0)
|
||||
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"
|
||||
|
||||
+120
-13
@@ -32,10 +32,11 @@ enum Type : uint8_t { BOX, TEXT, BUTTON, CUSTOM };
|
||||
|
||||
enum Flag : uint8_t {
|
||||
ROW = 1 << 0, // main axis is horizontal
|
||||
CAPTURE = 1 << 1, // swallows the taps its children missed
|
||||
INTERACTIVE = 1 << 2, // has an on_press
|
||||
DIRTY = 1 << 3,
|
||||
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 };
|
||||
@@ -118,6 +119,15 @@ struct Spec {
|
||||
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
|
||||
// painted.
|
||||
constexpr int SLOP = 4;
|
||||
@@ -145,6 +155,7 @@ public:
|
||||
labelAt.clear();
|
||||
labels.clear();
|
||||
styles.clear();
|
||||
scrollState.clear();
|
||||
error = nullptr;
|
||||
}
|
||||
|
||||
@@ -192,8 +203,10 @@ public:
|
||||
return error == nullptr;
|
||||
}
|
||||
|
||||
// Deepest interactive node wins, so a tappable child beats its tappable
|
||||
// parent. The list is singly linked, so "last match walking forward" stands
|
||||
// Deepest node by geometry wins, so a child beats its parent and a later sibling
|
||||
// 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.
|
||||
uint16_t hit(uint16_t id, int px, int py) const {
|
||||
const Node& n = nodes[id];
|
||||
@@ -210,9 +223,65 @@ public:
|
||||
}
|
||||
if (found != NONE)
|
||||
return found;
|
||||
if (n.flags & CAPTURE)
|
||||
return id;
|
||||
return (n.flags & INTERACTIVE) ? id : NONE;
|
||||
return id;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -302,6 +371,25 @@ private:
|
||||
std::vector<uint16_t> labelAt;
|
||||
std::vector<char> labels;
|
||||
std::vector<std::pair<uint16_t, Style>> styles;
|
||||
// Linear rather than the sorted search styles use: a screen has one or two
|
||||
// scroll containers, so the binary search would cost more than the scan.
|
||||
std::vector<std::pair<uint16_t, Scroll>> scrollState;
|
||||
|
||||
Scroll* mutableScroll(uint16_t id) {
|
||||
for (size_t at = 0; at < scrollState.size(); at++) {
|
||||
if (scrollState[at].first == id)
|
||||
return &scrollState[at].second;
|
||||
}
|
||||
scrollState.push_back(std::make_pair(id, Scroll()));
|
||||
return &scrollState.back().second;
|
||||
}
|
||||
|
||||
void shift(uint16_t id, int dx, int dy) {
|
||||
nodes[id].x = static_cast<int16_t>(nodes[id].x + dx);
|
||||
nodes[id].y = static_cast<int16_t>(nodes[id].y + dy);
|
||||
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next)
|
||||
shift(c, dx, dy);
|
||||
}
|
||||
|
||||
void fail(const char* message) {
|
||||
if (!error)
|
||||
@@ -345,6 +433,22 @@ private:
|
||||
// by its content cannot tell a child what fraction of it to take.
|
||||
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;
|
||||
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next) {
|
||||
measure(c, innerW, innerH);
|
||||
@@ -373,16 +477,19 @@ private:
|
||||
cross = row ? s.intrinsicH : s.intrinsicW;
|
||||
}
|
||||
|
||||
Node& self = nodes[id];
|
||||
int along = main + (row ? s.padL + s.padR : s.padT + s.padB);
|
||||
int across = cross + (row ? s.padT + s.padB : s.padL + s.padR);
|
||||
if (row) {
|
||||
self.w = static_cast<int16_t>(w != UNKNOWN ? w : along);
|
||||
self.h = static_cast<int16_t>(h != UNKNOWN ? h : across);
|
||||
} else {
|
||||
self.w = static_cast<int16_t>(w != UNKNOWN ? w : across);
|
||||
self.h = static_cast<int16_t>(h != UNKNOWN ? h : along);
|
||||
const int contentW = row ? along : across;
|
||||
const int contentH = row ? across : along;
|
||||
if (scrollFlags) {
|
||||
Scroll* state = mutableScroll(id);
|
||||
state->contentW = static_cast<int16_t>(contentW);
|
||||
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) {
|
||||
|
||||
@@ -35,15 +35,6 @@ struct MemoryInfo {
|
||||
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,
|
||||
// feature reporting and navigation are the runtime's, because it owns app
|
||||
// loading and knows which providers exist.
|
||||
@@ -53,6 +44,8 @@ public:
|
||||
virtual int32_t millis() const = 0;
|
||||
virtual MemoryInfo memory() 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
|
||||
@@ -111,7 +104,13 @@ public:
|
||||
virtual int32_t width() const = 0;
|
||||
virtual int32_t height() 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 void clear(int32_t color) = 0;
|
||||
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,
|
||||
int32_t radius, int32_t background, const int32_t* top,
|
||||
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
|
||||
// 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
|
||||
// clean up ghosting all stay here. A live LCD has nothing pending and does
|
||||
// nothing.
|
||||
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,
|
||||
int32_t color) = 0;
|
||||
// Null coordinates centre the image; null bounds fall back to the panel size.
|
||||
@@ -220,10 +246,24 @@ public:
|
||||
virtual Status forget() = 0;
|
||||
};
|
||||
|
||||
struct BleDevice {
|
||||
std::string name;
|
||||
// One sighting of an advertising device, coalesced to its latest advert. The
|
||||
// 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 name;
|
||||
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 {
|
||||
@@ -232,7 +272,10 @@ public:
|
||||
virtual Status init(const std::string* name) = 0;
|
||||
virtual void deinit() = 0;
|
||||
virtual bool isInitialized() const = 0;
|
||||
virtual Status scan(int32_t durationMs, std::vector<BleDevice>& devices) = 0;
|
||||
virtual Status observe(const BleFilter& filter) = 0;
|
||||
virtual void unobserve() = 0;
|
||||
virtual bool isObserving() const = 0;
|
||||
virtual void observed(std::vector<BleObservation>& out) = 0;
|
||||
virtual Status connect(const std::string& address) = 0;
|
||||
virtual void disconnect() = 0;
|
||||
virtual bool isConnected() const = 0;
|
||||
|
||||
@@ -15,36 +15,38 @@ namespace esp32lua {
|
||||
// build.
|
||||
constexpr int32_t API_VERSION = 1;
|
||||
|
||||
// Where the runtime looks for apps, their data, and shared modules.
|
||||
struct Paths {
|
||||
std::string apps = "/.lua/apps";
|
||||
std::string data = "/.lua/data";
|
||||
std::string lib = "/.lua/lib";
|
||||
// Where sys.back() lands once history is empty. It is an app like any other.
|
||||
std::string home = "Home";
|
||||
};
|
||||
// The path a firmware boots. Nothing else here knows it: startApp() takes
|
||||
// whatever path it is given, and where apps live, where their data goes and
|
||||
// what chrome surrounds them are decided by the Lua it loads.
|
||||
constexpr const char* MAIN_PATH = "/.lua/main.lua";
|
||||
|
||||
// Firmware supplies every core provider; a null feature provider is how
|
||||
// sys.hasFeature() answers false, and its namespace additions are simply never
|
||||
// registered.
|
||||
struct Providers {
|
||||
LogProvider* log = nullptr;
|
||||
SettingsProvider* settings = nullptr;
|
||||
SysProvider* sys = nullptr;
|
||||
FsProvider* fs = nullptr;
|
||||
GuiProvider* gui = nullptr;
|
||||
HttpProvider* http = nullptr;
|
||||
TimerProvider* timer = nullptr;
|
||||
WifiProvider* wifi = nullptr;
|
||||
BleProvider* ble = nullptr;
|
||||
|
||||
GuiProvider* gui = nullptr;
|
||||
TouchProvider* touch = nullptr;
|
||||
ButtonsProvider* buttons = nullptr;
|
||||
};
|
||||
|
||||
// The arguments a launch carries cross the teardown as JSON, because the table
|
||||
// they came from dies with the state that built it. Encoding raises, so an app
|
||||
// that passes a function sees the error at its own sys.startApp() call;
|
||||
// decoding cannot, because by then there is no app to report it to.
|
||||
std::string encodeJson(lua_State* state, int index);
|
||||
bool decodeJson(lua_State* state, const std::string& json);
|
||||
|
||||
class Runtime {
|
||||
public:
|
||||
explicit Runtime(const Providers& providers, const Paths& paths = Paths());
|
||||
explicit Runtime(const Providers& providers);
|
||||
~Runtime();
|
||||
|
||||
Runtime(const Runtime&) = delete;
|
||||
@@ -55,38 +57,27 @@ public:
|
||||
void close();
|
||||
lua_State* state() const { return state_; }
|
||||
|
||||
// Replaces the running app with a fresh lua_State, loads
|
||||
// <apps>/<path>/main.lua, and calls init(arg). A failure leaves no app
|
||||
// running rather than a half-built one.
|
||||
// Replaces the running app with a fresh lua_State, loads the path, and hands
|
||||
// the table it returns its arguments through start(args). A failure leaves no
|
||||
// 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,
|
||||
const std::string& arg = std::string());
|
||||
const std::string& argsJson = std::string());
|
||||
bool hasApp() const { return !appPath_.empty(); }
|
||||
// The app-relative route, its immutable first component, and the title the
|
||||
// app chose.
|
||||
// The path that was loaded, which is all the runtime knows about an app.
|
||||
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;
|
||||
|
||||
// sys.launch/replace/back record intent and return; swapping the lua_State
|
||||
// inside a callback would free the VM that is still executing. The firmware
|
||||
// applies it between batches.
|
||||
void requestLaunch(const std::string& path, const std::string& arg,
|
||||
bool replace);
|
||||
void requestBack();
|
||||
bool hasPendingNavigation() const { return pending_.kind != Pending::None; }
|
||||
// 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.
|
||||
// sys.startApp records intent and returns; swapping the lua_State inside a
|
||||
// callback would free the VM that is still executing. The firmware applies it
|
||||
// between batches.
|
||||
void requestStart(const std::string& path, const std::string& argsJson);
|
||||
bool hasPendingNavigation() const { return pending_.pending; }
|
||||
// Loads whatever was requested. False means the app failed to start, in which
|
||||
// case no app is running.
|
||||
bool applyPendingNavigation();
|
||||
|
||||
LogProvider& log() const { return *providers_.log; }
|
||||
SettingsProvider& settings() const { return *providers_.settings; }
|
||||
SysProvider& sys() const { return *providers_.sys; }
|
||||
FsProvider& fs() const { return *providers_.fs; }
|
||||
GuiProvider& gui() const { return *providers_.gui; }
|
||||
@@ -99,11 +90,11 @@ public:
|
||||
|
||||
ui::Tree& tree() { return tree_; }
|
||||
|
||||
// Entry points into the app. The firmware decides whether an event reaches
|
||||
// the app at all -- jitter, chrome and debouncing are its business -- and the
|
||||
// runtime decides what the app sees. Only a failed init() stops an app; every
|
||||
// other callback logs and carries on.
|
||||
bool callInit(const std::string& arg);
|
||||
// Entry points into main.lua, which forwards whatever the app it mounted
|
||||
// defines. The firmware decides whether an event happens at all -- jitter and
|
||||
// debouncing are its business -- and main.lua decides who sees it. Only a
|
||||
// failed start() stops an app; every other callback logs and carries on.
|
||||
bool callStart(const std::string& argsJson);
|
||||
void callDraw(int32_t deltaMs);
|
||||
// An Up phase also fires the on_touch tap alias, in that order.
|
||||
void callTouch(TouchPhase phase, int32_t x, int32_t y);
|
||||
@@ -125,18 +116,17 @@ private:
|
||||
bool repeating;
|
||||
};
|
||||
|
||||
struct Route {
|
||||
std::string path;
|
||||
std::string arg;
|
||||
};
|
||||
|
||||
struct Pending {
|
||||
enum Kind { None, Launch, Replace, Back } kind = None;
|
||||
Route route;
|
||||
bool pending = false;
|
||||
std::string path;
|
||||
std::string argsJson;
|
||||
};
|
||||
|
||||
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 searchEmbedded(lua_State* state);
|
||||
static int loadFile(lua_State* state);
|
||||
@@ -155,9 +145,10 @@ private:
|
||||
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 finishCall(const char* name, int argc);
|
||||
bool finishCallValue(const char* name);
|
||||
void cancelAllTimers();
|
||||
|
||||
Providers providers_;
|
||||
@@ -167,10 +158,10 @@ private:
|
||||
TimerId nextTimerId_ = 1;
|
||||
int batchDepth_ = 0;
|
||||
|
||||
Paths paths_;
|
||||
// Registry reference to the table the entry file returned, or 0 before one
|
||||
// loads.
|
||||
int mainRef_ = 0;
|
||||
std::string appPath_;
|
||||
std::string appTitle_;
|
||||
std::vector<Route> history_;
|
||||
Pending pending_;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// @lua-module ble BleLib
|
||||
// @lua-preamble ---@class BleDevice
|
||||
// @lua-preamble ---@field name string
|
||||
// @lua-preamble ---@class BleObservation
|
||||
// @lua-preamble ---@field address string
|
||||
// @lua-preamble ---@field name string
|
||||
// @lua-preamble ---@field rssi integer
|
||||
// @lua-preamble ---@field payload string Raw advertisement bytes.
|
||||
// @lua-preamble ---@field lastSeenMs integer sys.getMillis() at last sighting.
|
||||
// @lua-preamble
|
||||
// @lua-preamble ---@class BleFilter
|
||||
// @lua-preamble ---@field services? string[] Service-data UUIDs to keep.
|
||||
// @lua-preamble ---@field manufacturers? integer[] Manufacturer ids to keep.
|
||||
|
||||
#include "../helpers.h"
|
||||
|
||||
@@ -30,21 +36,67 @@ int isInitialized(lua_State* state) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
int scan(lua_State* state) {
|
||||
const int32_t durationMs = optionalInt(state, 1, 3000);
|
||||
luaL_argcheck(state, durationMs > 0, 1, "must be positive");
|
||||
void readStringArray(lua_State* state, int index, const char* key,
|
||||
std::vector<std::string>& 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_TSTRING)
|
||||
out.push_back(lua_tostring(state, -1));
|
||||
lua_pop(state, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(state, 1);
|
||||
}
|
||||
|
||||
std::vector<BleDevice> devices;
|
||||
const Status status = Runtime::from(state)->ble().scan(durationMs, devices);
|
||||
if (!status.ok)
|
||||
return pushError(state, status.error);
|
||||
void readIntArray(lua_State* state, int index, const char* key,
|
||||
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 observe(lua_State* state) {
|
||||
BleFilter filter;
|
||||
if (lua_istable(state, 1)) {
|
||||
readStringArray(state, 1, "services", filter.services);
|
||||
readIntArray(state, 1, "manufacturers", filter.manufacturers);
|
||||
}
|
||||
return pushStatus(state, Runtime::from(state)->ble().observe(filter));
|
||||
}
|
||||
|
||||
int unobserve(lua_State* state) {
|
||||
Runtime::from(state)->ble().unobserve();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int isObserving(lua_State* state) {
|
||||
lua_pushboolean(state, Runtime::from(state)->ble().isObserving());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int observed(lua_State* state) {
|
||||
std::vector<BleObservation> devices;
|
||||
Runtime::from(state)->ble().observed(devices);
|
||||
|
||||
lua_createtable(state, static_cast<int>(devices.size()), 0);
|
||||
for (size_t at = 0; at < devices.size(); at++) {
|
||||
lua_createtable(state, 0, 3);
|
||||
setField(state, "name", devices[at].name);
|
||||
lua_createtable(state, 0, 5);
|
||||
setField(state, "address", devices[at].address);
|
||||
setField(state, "name", devices[at].name);
|
||||
setField(state, "rssi", devices[at].rssi);
|
||||
setField(state, "payload", devices[at].payload);
|
||||
setField(state, "lastSeenMs", devices[at].lastSeenMs);
|
||||
lua_rawseti(state, -2, static_cast<lua_Integer>(at + 1));
|
||||
}
|
||||
return 1;
|
||||
@@ -108,11 +160,19 @@ const luaL_Reg FUNCTIONS[] = {
|
||||
// --- Whether the BLE stack is running.
|
||||
// @return boolean
|
||||
{"isInitialized", isInitialized},
|
||||
// --- Scans for advertising devices.
|
||||
// @param durationMs integer|nil Defaults to 3000.
|
||||
// @return BleDevice[]|nil devices
|
||||
// --- Starts passively observing advertisements, coalesced per device.
|
||||
// @param filter BleFilter|nil Keep only matching adverts; nil keeps all.
|
||||
// @return true|nil ok
|
||||
// @return string|nil error
|
||||
{"scan", scan},
|
||||
{"observe", observe},
|
||||
// --- Stops observing and clears the snapshot.
|
||||
{"unobserve", unobserve},
|
||||
// --- Whether advertisement observation is running.
|
||||
// @return boolean
|
||||
{"isObserving", isObserving},
|
||||
// --- The current snapshot of observed devices.
|
||||
// @return BleObservation[] devices
|
||||
{"observed", observed},
|
||||
// --- Connects to a peripheral.
|
||||
// @param address string
|
||||
// @return true|nil ok
|
||||
|
||||
@@ -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-preamble ---@alias Feature "touch"|"buttons"
|
||||
// @lua-preamble ---@alias Feature "screen"|"touch"|"buttons"
|
||||
|
||||
#include "../helpers.h"
|
||||
|
||||
@@ -20,34 +20,17 @@ int getMillis(lua_State* state) {
|
||||
lua_pushinteger(state, Runtime::from(state)->sys().millis());
|
||||
return 1;
|
||||
}
|
||||
int getAppID(lua_State* state) {
|
||||
pushString(state, Runtime::from(state)->appId());
|
||||
return 1;
|
||||
}
|
||||
int getAppTitle(lua_State* state) {
|
||||
pushString(state, Runtime::from(state)->appTitle());
|
||||
return 1;
|
||||
}
|
||||
int getAppDataPath(lua_State* state) {
|
||||
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) {
|
||||
// Encoding happens here, in the state that still holds the table, so an app
|
||||
// passing something JSON cannot carry raises at its own call rather than
|
||||
// stranding the launch.
|
||||
int startApp(lua_State* state) {
|
||||
luaL_checkstring(state, 1);
|
||||
if (!lua_isnoneornil(state, 2))
|
||||
luaL_checktype(state, 2, LUA_TTABLE);
|
||||
const std::string json =
|
||||
lua_isnoneornil(state, 2) ? std::string() : encodeJson(state, 2);
|
||||
const std::string path = checkString(state, 1);
|
||||
const std::string arg =
|
||||
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();
|
||||
Runtime::from(state)->requestStart(path, json);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -62,6 +45,14 @@ int isClockSynced(lua_State* state) {
|
||||
lua_pushboolean(state, Runtime::from(state)->sys().isClockSynced());
|
||||
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[] = {
|
||||
// --- Returns the implemented API contract version.
|
||||
@@ -74,30 +65,14 @@ const luaL_Reg FUNCTIONS[] = {
|
||||
// --- Returns monotonic milliseconds since boot.
|
||||
// @return integer
|
||||
{"getMillis", getMillis},
|
||||
// --- Returns the immutable first path component of the running app.
|
||||
// @return string
|
||||
{"getAppID", getAppID},
|
||||
// --- Returns the running app title, initially the app ID.
|
||||
// @return string
|
||||
{"getAppTitle", getAppTitle},
|
||||
// --- Returns the current app's guaranteed-existing persistent data
|
||||
// directory.
|
||||
// @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},
|
||||
// --- 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.
|
||||
// @param path string Absolute path to the Lua file to load; traversal is
|
||||
// rejected.
|
||||
// @param args table|nil Plain data, carried across the teardown as JSON and
|
||||
// handed to start(args). Raises on anything JSON cannot represent.
|
||||
{"startApp", startApp},
|
||||
// --- Returns heap statistics.
|
||||
// @return integer freeBytes
|
||||
// @return integer totalBytes
|
||||
@@ -106,6 +81,14 @@ const luaL_Reg FUNCTIONS[] = {
|
||||
// --- Whether network time synchronization has completed.
|
||||
// @return boolean
|
||||
{"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},
|
||||
};
|
||||
|
||||
|
||||
@@ -32,18 +32,18 @@ int wasReleased(lua_State* state) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// @lua-augment input InputLib
|
||||
// @lua-module buttons ButtonsLib
|
||||
// @lua-preamble ---@alias Button "up"|"down"|"left"|"right"|"confirm"|"back"
|
||||
// @lua-preamble
|
||||
// @lua-preamble -- Roles, not physical buttons: a device maps whatever hardware
|
||||
// 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.
|
||||
const luaL_Reg INPUT_FUNCTIONS[] = {
|
||||
const luaL_Reg FUNCTIONS[] = {
|
||||
// ---Returns the roles this device reports, so an app can label only the
|
||||
// actions it has.
|
||||
// @return Button[]
|
||||
{"getButtons", getButtons},
|
||||
{"getAll", getButtons},
|
||||
// ---Whether any button is held.
|
||||
// @return boolean
|
||||
{"isAnyPressed", isAnyPressed},
|
||||
@@ -65,19 +65,24 @@ const luaL_Reg INPUT_FUNCTIONS[] = {
|
||||
} // namespace
|
||||
|
||||
void registerButtons(lua_State* state) {
|
||||
augmentGlobal(state, "input", INPUT_FUNCTIONS);
|
||||
luaL_newlib(state, FUNCTIONS);
|
||||
lua_setglobal(state, "buttons");
|
||||
}
|
||||
|
||||
} // namespace bindings
|
||||
} // 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.
|
||||
// @param button Button
|
||||
// @lua-fn on_button_down
|
||||
// @lua-fn onButtonDown?
|
||||
// ---Fired when a button comes up.
|
||||
// @param button Button
|
||||
// @lua-fn on_button_up
|
||||
// ---Tap alias, fired on release like a click, after on_button_up.
|
||||
// @lua-fn onButtonUp?
|
||||
// ---Tap alias, fired on release like a click, after onButtonUp.
|
||||
// @param button Button
|
||||
// @lua-fn on_button
|
||||
// @lua-fn onButton?
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
// @lua-module gui GuiLib
|
||||
// @lua-preamble ---@alias GuiColor integer
|
||||
// @lua-preamble ---@alias GuiFont integer
|
||||
// @lua-preamble ---@alias GuiTextStyle integer
|
||||
// @lua-const FONT_SMALL GuiFont 0 Small auxiliary text.
|
||||
// @lua-const FONT_UI GuiFont 0 Normal controls and labels.
|
||||
// @lua-const FONT_BODY GuiFont 0 Normal reading text.
|
||||
// @lua-const FONT_LARGE GuiFont 0 Headings and prominent values.
|
||||
// @lua-const STYLE_NORMAL GuiTextStyle 0
|
||||
// @lua-const STYLE_BOLD GuiTextStyle 0
|
||||
// @lua-module screen ScreenLib
|
||||
// @lua-preamble -- The panel itself; the widget tree it paints is `tree`, and
|
||||
// @lua-preamble -- sys.hasFeature("screen") covers both.
|
||||
// @lua-preamble ---@alias ScreenColor integer
|
||||
// @lua-preamble ---@alias ScreenFont integer
|
||||
// @lua-preamble ---@alias ScreenTextStyle integer
|
||||
// @lua-const FONT_SMALL ScreenFont 0 Small auxiliary text.
|
||||
// @lua-const FONT_UI ScreenFont 0 Normal controls and labels.
|
||||
// @lua-const FONT_BODY ScreenFont 0 Normal reading text.
|
||||
// @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 bindings {
|
||||
@@ -36,8 +38,17 @@ int setRotation(lua_State* state) {
|
||||
const int32_t degrees = checkInt(state, 1);
|
||||
luaL_argcheck(state, degrees >= 0 && degrees <= 270 && degrees % 90 == 0, 1,
|
||||
"expected 0, 90, 180, or 270");
|
||||
provider(state).setRotation(degrees);
|
||||
return 0;
|
||||
return pushStatus(state, provider(state).setRotation(degrees));
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -125,12 +136,6 @@ int roundRect(lua_State* state) {
|
||||
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) {
|
||||
const lua_Integer count = luaL_len(state, index);
|
||||
for (lua_Integer at = 1; at <= count; at++) {
|
||||
@@ -221,62 +226,76 @@ const luaL_Reg FUNCTIONS[] = {
|
||||
// --- Returns the live frame height.
|
||||
// @return integer
|
||||
{"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.
|
||||
// @return true|nil ok
|
||||
// @return string|nil error
|
||||
{"setRotation", setRotation},
|
||||
// --- Returns the rotation of the live frame.
|
||||
// @return integer Degrees clockwise for the live frame.
|
||||
// --- Returns the rotation in degrees clockwise.
|
||||
// @return integer
|
||||
{"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
|
||||
// available grayscale.
|
||||
// @param r integer 0 through 255.
|
||||
// @param g integer 0 through 255.
|
||||
// @param b integer 0 through 255.
|
||||
// @return GuiColor
|
||||
// @return ScreenColor
|
||||
{"color", color},
|
||||
// --- Clears the frame.
|
||||
// @param color GuiColor|nil Defaults to white.
|
||||
// @param color ScreenColor|nil Defaults to white.
|
||||
{"clear", clear},
|
||||
// --- Fills a rectangle.
|
||||
// @param x integer
|
||||
// @param y integer
|
||||
// @param w integer
|
||||
// @param h integer
|
||||
// @param color GuiColor
|
||||
// @param color ScreenColor
|
||||
{"fillRect", fillRect},
|
||||
// --- Outlines a rectangle.
|
||||
// @param x integer
|
||||
// @param y integer
|
||||
// @param w integer
|
||||
// @param h integer
|
||||
// @param color GuiColor
|
||||
// @param color ScreenColor
|
||||
{"drawRect", drawRect},
|
||||
// --- Draws a line.
|
||||
// @param x1 integer
|
||||
// @param y1 integer
|
||||
// @param x2 integer
|
||||
// @param y2 integer
|
||||
// @param color GuiColor
|
||||
// @param color ScreenColor
|
||||
// @param width integer|nil Defaults to one pixel.
|
||||
{"drawLine", drawLine},
|
||||
// --- Draws a single pixel.
|
||||
// @param x integer
|
||||
// @param y integer
|
||||
// @param color GuiColor
|
||||
// @param color ScreenColor
|
||||
{"drawPixel", drawPixel},
|
||||
// --- Outlines a circle.
|
||||
// @param x integer Center.
|
||||
// @param y integer Center.
|
||||
// @param radius integer
|
||||
// @param color GuiColor
|
||||
// @param color ScreenColor
|
||||
// @param width integer|nil Defaults to one pixel.
|
||||
{"drawCircle", drawCircle},
|
||||
// --- Fills a circle.
|
||||
// @param x integer Center.
|
||||
// @param y integer Center.
|
||||
// @param radius integer
|
||||
// @param color GuiColor
|
||||
// @param background GuiColor|nil Surface behind an anti-aliased edge.
|
||||
// @param color ScreenColor
|
||||
// @param background ScreenColor|nil Surface behind an anti-aliased edge.
|
||||
{"fillCircle", fillCircle},
|
||||
// ---Draws an anti-aliased rounded fill, optional gradient, and optional
|
||||
// border in one pass.
|
||||
@@ -285,19 +304,16 @@ const luaL_Reg FUNCTIONS[] = {
|
||||
// @param w integer
|
||||
// @param h integer
|
||||
// @param radius integer
|
||||
// @param background GuiColor Surface behind the anti-aliased edge.
|
||||
// @param top GuiColor|nil Fill, or gradient top; omitted for no fill.
|
||||
// @param bottom GuiColor|nil Gradient bottom; defaults to top. Panels
|
||||
// @param background ScreenColor Surface behind the anti-aliased edge.
|
||||
// @param top ScreenColor|nil Fill, or gradient top; omitted for no fill.
|
||||
// @param bottom ScreenColor|nil Gradient bottom; defaults to top. Panels
|
||||
// without a gradient use top.
|
||||
// @param border GuiColor|nil Omitted for no border.
|
||||
// @param border ScreenColor|nil Omitted for no border.
|
||||
{"roundRect", roundRect},
|
||||
// ---Temporarily gives the app the full panel, including firmware chrome.
|
||||
// @param on boolean
|
||||
{"setFullscreen", setFullscreen},
|
||||
// --- Fills a polygon.
|
||||
// @param xs integer[]
|
||||
// @param ys integer[]
|
||||
// @param color GuiColor
|
||||
// @param color ScreenColor
|
||||
{"fillPolygon", fillPolygon},
|
||||
// --- Draws a bitmap.
|
||||
// @param path string Absolute BMP path.
|
||||
@@ -309,31 +325,31 @@ const luaL_Reg FUNCTIONS[] = {
|
||||
// @return string|nil error
|
||||
{"drawBmp", drawBmp},
|
||||
// --- 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 style GuiTextStyle|nil Defaults to gui.STYLE_NORMAL.
|
||||
// @param style ScreenTextStyle|nil Defaults to screen.STYLE_NORMAL.
|
||||
// @return integer
|
||||
{"getTextWidth", getTextWidth},
|
||||
// --- Returns the line height of a font role.
|
||||
// @param font GuiFont Use a named gui.FONT_* role.
|
||||
// @param style GuiTextStyle|nil Defaults to gui.STYLE_NORMAL.
|
||||
// @param font ScreenFont Use a named screen.FONT_* role.
|
||||
// @param style ScreenTextStyle|nil Defaults to screen.STYLE_NORMAL.
|
||||
// @return integer
|
||||
{"getFontHeight", getFontHeight},
|
||||
// --- 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 y integer Top edge.
|
||||
// @param text string
|
||||
// @param color GuiColor|nil Defaults to black.
|
||||
// @param style GuiTextStyle|nil Defaults to gui.STYLE_NORMAL.
|
||||
// @param background GuiColor|nil Omitted for transparent text.
|
||||
// @param color ScreenColor|nil Defaults to black.
|
||||
// @param style ScreenTextStyle|nil Defaults to screen.STYLE_NORMAL.
|
||||
// @param background ScreenColor|nil Omitted for transparent text.
|
||||
{"drawText", drawText},
|
||||
{nullptr, nullptr},
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
void registerGui(lua_State* state) {
|
||||
void registerScreen(lua_State* state) {
|
||||
luaL_newlib(state, FUNCTIONS);
|
||||
const FontIds fonts = Runtime::from(state)->gui().fonts();
|
||||
setField(state, "FONT_SMALL", fonts.small);
|
||||
@@ -342,7 +358,7 @@ void registerGui(lua_State* state) {
|
||||
setField(state, "FONT_LARGE", fonts.large);
|
||||
setField(state, "STYLE_NORMAL", fonts.styleNormal);
|
||||
setField(state, "STYLE_BOLD", fonts.styleBold);
|
||||
lua_setglobal(state, "gui");
|
||||
lua_setglobal(state, "screen");
|
||||
}
|
||||
|
||||
} // namespace bindings
|
||||
@@ -1,4 +1,4 @@
|
||||
// @lua-module node NodeLib
|
||||
// @lua-module tree TreeLib
|
||||
// @lua-preamble ---@alias NodeId integer
|
||||
// @lua-preamble ---@alias NodeType "box"|"text"|"button"|"custom"
|
||||
// @lua-preamble ---@alias NodeDirection "up"|"down"|"left"|"right"
|
||||
@@ -13,30 +13,29 @@
|
||||
// @lua-preamble ---@field justify? "start"|"center"|"end"|"between"
|
||||
// @lua-preamble ---@field row? boolean
|
||||
// @lua-preamble ---@field at? table Absolute-position fields.
|
||||
// @lua-preamble ---@field capture? boolean
|
||||
// @lua-preamble ---@field interactive? boolean
|
||||
// @lua-preamble ---@field label? string
|
||||
// @lua-preamble ---@field font? GuiFont
|
||||
// @lua-preamble ---@field font? ScreenFont
|
||||
// @lua-preamble
|
||||
// @lua-preamble ---@class NodeStyle
|
||||
// @lua-preamble ---@field color? GuiColor
|
||||
// @lua-preamble ---@field background? GuiColor Background offered to
|
||||
// @lua-preamble ---@field color? ScreenColor
|
||||
// @lua-preamble ---@field background? ScreenColor Background offered to
|
||||
// descendants.
|
||||
// @lua-preamble ---@field fill? GuiColor Surface painted by a box.
|
||||
// @lua-preamble ---@field border? GuiColor
|
||||
// @lua-preamble ---@field face? GuiColor Default button surface.
|
||||
// @lua-preamble ---@field pressedFace? GuiColor Pressed button surface.
|
||||
// @lua-preamble ---@field pressedColor? GuiColor Pressed button text.
|
||||
// @lua-preamble ---@field focusColor? GuiColor Distinct outline for directional
|
||||
// focus.
|
||||
// @lua-preamble ---@field fill? ScreenColor Surface painted by a box.
|
||||
// @lua-preamble ---@field border? ScreenColor
|
||||
// @lua-preamble ---@field face? ScreenColor Default button surface.
|
||||
// @lua-preamble ---@field pressedFace? ScreenColor Pressed button surface.
|
||||
// @lua-preamble ---@field pressedColor? ScreenColor Pressed button text.
|
||||
// @lua-preamble ---@field focusColor? ScreenColor Distinct outline for
|
||||
// directional focus.
|
||||
// @lua-preamble ---@field radius? integer
|
||||
// @lua-preamble ---@field font? GuiFont
|
||||
// @lua-preamble ---@field textStyle? GuiTextStyle
|
||||
// @lua-preamble ---@field font? ScreenFont
|
||||
// @lua-preamble ---@field textStyle? ScreenTextStyle
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
#include "../../node/painter.h"
|
||||
#include "../helpers.h"
|
||||
#include "../../../node/painter.h"
|
||||
#include "../../helpers.h"
|
||||
|
||||
namespace esp32lua {
|
||||
namespace bindings {
|
||||
@@ -173,10 +172,12 @@ int create(lua_State* state) {
|
||||
uint8_t flags = 0;
|
||||
if (readFlag(state, 2, "row"))
|
||||
flags |= ui::ROW;
|
||||
if (readFlag(state, 2, "capture"))
|
||||
flags |= ui::CAPTURE;
|
||||
if (readFlag(state, 2, "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");
|
||||
const int32_t font = lua_isnoneornil(state, -1)
|
||||
@@ -270,6 +271,27 @@ int getRect(lua_State* state) {
|
||||
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) {
|
||||
const uint16_t id = checkNode(state, 1);
|
||||
const char* text = luaL_checkstring(state, 2);
|
||||
@@ -467,7 +489,8 @@ int moveFocus(lua_State* state) {
|
||||
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_getfield(state, LUA_REGISTRYINDEX, PAINTER_KEY);
|
||||
if (!lua_isfunction(state, -1)) {
|
||||
@@ -479,7 +502,11 @@ void callPainter(void* context, uint16_t id, int x, int y, int w, int h) {
|
||||
lua_pushinteger(state, y);
|
||||
lua_pushinteger(state, w);
|
||||
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(
|
||||
LogLevel::Error,
|
||||
lua_tostring(state, -1) ? lua_tostring(state, -1) : "painter");
|
||||
@@ -554,6 +581,21 @@ const luaL_Reg FUNCTIONS[] = {
|
||||
// @return integer w
|
||||
// @return integer h
|
||||
{"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.
|
||||
// @param id NodeId
|
||||
// @param text string
|
||||
@@ -597,9 +639,12 @@ const luaL_Reg FUNCTIONS[] = {
|
||||
// @param direction NodeDirection
|
||||
// @return NodeId|nil focused Current focus when no candidate exists.
|
||||
{"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:
|
||||
// integer)
|
||||
// integer, clipX: integer, clipY: integer, clipW: integer, clipH: integer)
|
||||
{"setPainter", setPainter},
|
||||
// --- Paints dirty nodes; the firmware owns publication to the physical
|
||||
// display.
|
||||
@@ -616,9 +661,9 @@ const luaL_Reg FUNCTIONS[] = {
|
||||
|
||||
} // namespace
|
||||
|
||||
void registerNode(lua_State* state) {
|
||||
void registerTree(lua_State* state) {
|
||||
luaL_newlib(state, FUNCTIONS);
|
||||
lua_setglobal(state, "node");
|
||||
lua_setglobal(state, "tree");
|
||||
}
|
||||
|
||||
} // namespace bindings
|
||||
@@ -40,8 +40,21 @@ int isTouched(lua_State* state) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// @lua-augment settings SettingsLib
|
||||
const luaL_Reg SETTINGS_FUNCTIONS[] = {
|
||||
// @lua-module touch TouchLib
|
||||
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.
|
||||
// @param x0 integer Raw reading at the left edge.
|
||||
// @param y0 integer Raw reading at the top edge.
|
||||
@@ -53,49 +66,35 @@ const luaL_Reg SETTINGS_FUNCTIONS[] = {
|
||||
{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
|
||||
|
||||
void registerTouch(lua_State* state) {
|
||||
augmentGlobal(state, "settings", SETTINGS_FUNCTIONS);
|
||||
augmentGlobal(state, "input", INPUT_FUNCTIONS);
|
||||
luaL_newlib(state, FUNCTIONS);
|
||||
lua_setglobal(state, "touch");
|
||||
}
|
||||
|
||||
} // namespace bindings
|
||||
} // 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.
|
||||
// @param x integer
|
||||
// @param y integer
|
||||
// @lua-fn on_touch_down
|
||||
// @lua-fn onTouchDown?
|
||||
// ---Fired when the finger moves while down, after the firmware's jitter
|
||||
// filter.
|
||||
// @param x integer
|
||||
// @param y integer
|
||||
// @lua-fn on_touch_move
|
||||
// @lua-fn onTouchMove?
|
||||
// ---Fired when the finger lifts.
|
||||
// @param x integer
|
||||
// @param y integer
|
||||
// @lua-fn on_touch_up
|
||||
// ---Tap alias, fired on release like a click, after on_touch_up.
|
||||
// @lua-fn onTouchUp?
|
||||
// ---Tap alias, fired on release like a click, after onTouchUp.
|
||||
// @param x 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
+220
-20
@@ -1,10 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
// 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,
|
||||
// because a parent's fill lands on top of whatever they drew; nothing tracks
|
||||
// sub-regions, and a widget that wants to repaint part of itself is a CUSTOM
|
||||
// node painting through the gui bindings.
|
||||
// and an e-ink panel. The dirty region is composited a band at a time into an
|
||||
// offscreen buffer and pushed once, so a full-pane repaint -- a scroll -- moves
|
||||
// smoothly instead of stalling the bus per primitive. The tree is the display
|
||||
// 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/providers.h>
|
||||
@@ -12,8 +16,21 @@
|
||||
namespace esp32lua {
|
||||
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,
|
||||
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 {
|
||||
public:
|
||||
@@ -23,22 +40,188 @@ public:
|
||||
void* context = nullptr;
|
||||
|
||||
void draw(uint16_t id) {
|
||||
if (tree.nodes[id].flags & DIRTY) {
|
||||
paint(id);
|
||||
tree.nodes[id].flags &= ~DIRTY;
|
||||
for (uint16_t c = tree.nodes[id].first; c != NONE;
|
||||
c = tree.nodes[c].next) {
|
||||
tree.nodes[c].flags |= DIRTY;
|
||||
}
|
||||
}
|
||||
for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next)
|
||||
draw(c);
|
||||
Rect dirty;
|
||||
collectDirty(id, false, dirty, Box::unbounded());
|
||||
if (dirty.empty())
|
||||
return;
|
||||
if (!drawBanded(id, dirty))
|
||||
drawDirect(id);
|
||||
clearDirty(id);
|
||||
}
|
||||
|
||||
private:
|
||||
GuiProvider& gui;
|
||||
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
|
||||
// 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,
|
||||
@@ -56,7 +239,7 @@ private:
|
||||
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];
|
||||
switch (n.type) {
|
||||
case BUTTON:
|
||||
@@ -65,13 +248,29 @@ private:
|
||||
case TEXT:
|
||||
paintText(id);
|
||||
break;
|
||||
case CUSTOM:
|
||||
case CUSTOM: {
|
||||
// Cleared first, because a custom painter draws what it wants and nothing
|
||||
// knows what it drew last time.
|
||||
gui.fillRect(n.x, n.y, n.w, n.h, tree.inherited(id, S_BG).bg);
|
||||
// knows what it drew last time. Only the band's slice of the node is
|
||||
// cleared, because each band clears its own before repainting it.
|
||||
const int x0 = n.x > clipX ? n.x : clipX;
|
||||
const int y0 = n.y > clipY ? n.y : clipY;
|
||||
const int nx1 = n.x + n.w, ny1 = n.y + n.h;
|
||||
const int x1 = nx1 < clipX + clipW ? nx1 : clipX + clipW;
|
||||
const int y1 = ny1 < clipY + clipH ? ny1 : clipY + clipH;
|
||||
if (x1 <= x0 || y1 <= y0)
|
||||
break;
|
||||
// Scissored to the node, not just to the band: a custom painter draws what
|
||||
// it likes and a band is usually taller than the node, so the overflow of
|
||||
// a row half scrolled off the top would otherwise paint over the chrome
|
||||
// above it. Culling cannot replace this -- the row is meant to be drawn,
|
||||
// just cut off at the node's edge.
|
||||
const Box previous = pushClip(clip.clipTo(x0, y0, x1 - x0, y1 - y0));
|
||||
gui.fillRect(x0, y0, x1 - x0, y1 - y0, tree.inherited(id, S_BG).bg);
|
||||
if (custom)
|
||||
custom(context, id, n.x, n.y, n.w, n.h);
|
||||
custom(context, id, n.x, n.y, n.w, n.h, x0, y0, x1 - x0, y1 - y0);
|
||||
popClip(previous);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
paintBox(id);
|
||||
break;
|
||||
@@ -80,6 +279,7 @@ private:
|
||||
paintFocus(id);
|
||||
}
|
||||
|
||||
|
||||
void paintBox(uint16_t id) {
|
||||
const Node& n = tree.nodes[id];
|
||||
const Style* own = tree.styleOf(id);
|
||||
|
||||
@@ -123,13 +123,11 @@ int Runtime::loadFile(lua_State* state) {
|
||||
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");
|
||||
|
||||
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
|
||||
// misleading errors about shared objects that were never there.
|
||||
lua_getfield(state_, -1, "searchers");
|
||||
|
||||
+107
-99
@@ -11,12 +11,12 @@ namespace bindings {
|
||||
void registerBle(lua_State* state);
|
||||
void registerButtons(lua_State* state);
|
||||
void registerFs(lua_State* state);
|
||||
void registerGui(lua_State* state);
|
||||
void registerHttp(lua_State* state);
|
||||
void registerJson(lua_State* state);
|
||||
void registerLog(lua_State* state);
|
||||
void registerNode(lua_State* state);
|
||||
void registerSettings(lua_State* state);
|
||||
void registerScreen(lua_State* state);
|
||||
void registerSys(lua_State* state);
|
||||
void registerTree(lua_State* state);
|
||||
void registerTimer(lua_State* state);
|
||||
void registerTouch(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
|
||||
// component is a hard no.
|
||||
bool isSafeRoute(const std::string& path) {
|
||||
if (path.empty() || path[0] == '/')
|
||||
// Absolute, and no component that could climb out of the card. Apps are
|
||||
// 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;
|
||||
size_t start = 0;
|
||||
size_t start = 1;
|
||||
while (start <= path.size()) {
|
||||
const size_t end = path.find('/', start);
|
||||
const std::string part = path.substr(
|
||||
@@ -45,17 +48,16 @@ bool isSafeRoute(const std::string& path) {
|
||||
|
||||
} // namespace
|
||||
|
||||
Runtime::Runtime(const Providers& providers, const Paths& paths)
|
||||
: providers_(providers), paths_(paths) {}
|
||||
Runtime::Runtime(const Providers& providers) : providers_(providers) {}
|
||||
|
||||
Runtime::~Runtime() { close(); }
|
||||
|
||||
bool Runtime::open() {
|
||||
if (state_)
|
||||
return true;
|
||||
if (!providers_.log || !providers_.settings || !providers_.sys ||
|
||||
!providers_.fs || !providers_.gui || !providers_.http ||
|
||||
!providers_.timer || !providers_.wifi || !providers_.ble) {
|
||||
if (!providers_.log || !providers_.sys || !providers_.fs ||
|
||||
!providers_.http || !providers_.timer || !providers_.wifi ||
|
||||
!providers_.ble) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -65,19 +67,21 @@ bool Runtime::open() {
|
||||
*static_cast<Runtime**>(lua_getextraspace(state_)) = this;
|
||||
luaL_openlibs(state_);
|
||||
|
||||
// Primary Namespaces
|
||||
bindings::registerBle(state_);
|
||||
bindings::registerFs(state_);
|
||||
bindings::registerGui(state_);
|
||||
bindings::registerHttp(state_);
|
||||
bindings::registerJson(state_);
|
||||
bindings::registerLog(state_);
|
||||
bindings::registerNode(state_);
|
||||
bindings::registerSettings(state_);
|
||||
bindings::registerSys(state_);
|
||||
bindings::registerTimer(state_);
|
||||
bindings::registerWifi(state_);
|
||||
|
||||
// Feature namespaces extend the tables the core registrations just created,
|
||||
// so they always follow them.
|
||||
// Feature Namespaces
|
||||
if (providers_.gui) {
|
||||
bindings::registerScreen(state_);
|
||||
bindings::registerTree(state_);
|
||||
}
|
||||
if (providers_.touch)
|
||||
bindings::registerTouch(state_);
|
||||
if (providers_.buttons)
|
||||
@@ -91,11 +95,9 @@ void Runtime::close() {
|
||||
cancelAllTimers();
|
||||
lua_close(state_);
|
||||
state_ = nullptr;
|
||||
// Node handles mean nothing to the next lua_State, so an app that inherited
|
||||
// the previous tree would build onto its nodes.
|
||||
mainRef_ = 0;
|
||||
tree_.reset();
|
||||
appPath_.clear();
|
||||
appTitle_.clear();
|
||||
}
|
||||
|
||||
Runtime::Batch::Batch(Runtime& runtime) : runtime_(runtime) {
|
||||
@@ -103,18 +105,34 @@ Runtime::Batch::Batch(Runtime& runtime) : runtime_(runtime) {
|
||||
}
|
||||
|
||||
Runtime::Batch::~Batch() {
|
||||
if (--runtime_.batchDepth_ == 0)
|
||||
if (--runtime_.batchDepth_ == 0 && runtime_.providers_.gui)
|
||||
runtime_.providers_.gui->commit();
|
||||
}
|
||||
|
||||
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))
|
||||
return true;
|
||||
lua_pop(state_, 1);
|
||||
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) {
|
||||
if (lua_pcall(state_, argc, 0, 0) == LUA_OK)
|
||||
return true;
|
||||
@@ -125,18 +143,16 @@ bool Runtime::finishCall(const char* name, int argc) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// @lua-global core/runtime
|
||||
// @lua-preamble -- Runtime layout:
|
||||
// @lua-preamble -- /.lua/apps/<AppId>/main.lua application entry
|
||||
// point
|
||||
// @lua-preamble -- /.lua/apps/<AppId>/<Subapp>/main.lua nested route,
|
||||
// omitted from the launcher
|
||||
// @lua-preamble -- /.lua/data/<AppId>/ persistent app
|
||||
// data, preserved across updates
|
||||
// @lua-preamble -- /.lua/lib/<module>.lua shared require()
|
||||
// modules
|
||||
// @lua-preamble -- require() also searches the running application's
|
||||
// directory
|
||||
// @lua-app App core/runtime
|
||||
// @lua-preamble -- The firmware loads the path it was booted with into every
|
||||
// fresh state and calls
|
||||
// @lua-preamble -- these on the table it returns. Where apps live, what
|
||||
// surrounds them and which of
|
||||
// @lua-preamble -- these an app itself sees are all that file's to decide,
|
||||
// which is why an app
|
||||
// @lua-preamble -- composes the classes for the features it handles:
|
||||
// @lua-preamble --
|
||||
// @lua-preamble -- ---@class PaintApp : App, TouchHandlers
|
||||
// @lua-preamble --
|
||||
// @lua-preamble -- The firmware does not clear the frame before calling draw(),
|
||||
// and commits changed
|
||||
@@ -145,24 +161,34 @@ bool Runtime::finishCall(const char* name, int argc) {
|
||||
// @lua-preamble -- Timer callbacks are registered directly with
|
||||
// timer.after/every.
|
||||
|
||||
// ---Required. Runs once before the first draw; failing here stops the app.
|
||||
// @param arg string|nil The string passed to sys.launch or sys.replace.
|
||||
// @lua-fn init
|
||||
bool Runtime::callInit(const std::string& arg) {
|
||||
// ---Required. Mounts whatever the arguments describe; failing here leaves no
|
||||
// app running.
|
||||
// @param args table|nil The table passed to sys.startApp, carried across the
|
||||
// teardown as JSON.
|
||||
// @lua-fn start
|
||||
bool Runtime::callStart(const std::string& argsJson) {
|
||||
const Batch batch(*this);
|
||||
if (!beginCall("init")) {
|
||||
providers_.log->write(LogLevel::Error, "init: the app defines none");
|
||||
if (!beginCall("start")) {
|
||||
providers_.log->write(LogLevel::Error,
|
||||
"start: the entry file defines none");
|
||||
return false;
|
||||
}
|
||||
lua_pushlstring(state_, arg.data(), arg.size());
|
||||
return finishCall("init", 1);
|
||||
if (argsJson.empty()) {
|
||||
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.
|
||||
// @param deltaMs integer Monotonic milliseconds since the previous draw; zero
|
||||
// on the first.
|
||||
// @lua-fn draw
|
||||
// @lua-fn draw?
|
||||
void Runtime::callDraw(int32_t deltaMs) {
|
||||
const Batch batch(*this);
|
||||
if (!beginCall("draw"))
|
||||
@@ -180,16 +206,16 @@ void Runtime::callTouch(TouchPhase phase, int32_t x, int32_t y) {
|
||||
const Batch batch(*this);
|
||||
const char* name =
|
||||
phase == TouchPhase::Down
|
||||
? "on_touch_down"
|
||||
: (phase == TouchPhase::Move ? "on_touch_move" : "on_touch_up");
|
||||
? "onTouchDown"
|
||||
: (phase == TouchPhase::Move ? "onTouchMove" : "onTouchUp");
|
||||
for (int pass = 0; pass < 2; pass++) {
|
||||
// The tap alias is ordering, not policy: a release always fires on_touch_up
|
||||
// and then on_touch, so both firmwares agree without either of them
|
||||
// The tap alias is ordering, not policy: a release always fires onTouchUp
|
||||
// and then onTouch, so both firmwares agree without either of them
|
||||
// deciding anything.
|
||||
if (pass == 1) {
|
||||
if (phase != TouchPhase::Up)
|
||||
return;
|
||||
name = "on_touch";
|
||||
name = "onTouch";
|
||||
}
|
||||
if (!beginCall(name))
|
||||
continue;
|
||||
@@ -206,12 +232,12 @@ void Runtime::callButton(const std::string& button, bool pressed) {
|
||||
return;
|
||||
}
|
||||
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++) {
|
||||
if (pass == 1) {
|
||||
if (pressed)
|
||||
return;
|
||||
name = "on_button";
|
||||
name = "onButton";
|
||||
}
|
||||
if (!beginCall(name))
|
||||
continue;
|
||||
@@ -275,14 +301,9 @@ void Runtime::cancelAllTimers() {
|
||||
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 {
|
||||
if (feature == "screen")
|
||||
return providers_.gui != nullptr;
|
||||
if (feature == "touch")
|
||||
return providers_.touch != nullptr;
|
||||
if (feature == "buttons")
|
||||
@@ -290,8 +311,8 @@ bool Runtime::hasFeature(const std::string& feature) const {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Runtime::startApp(const std::string& path, const std::string& arg) {
|
||||
if (!isSafeRoute(path)) {
|
||||
bool Runtime::startApp(const std::string& path, const std::string& argsJson) {
|
||||
if (!isSafePath(path)) {
|
||||
providers_.log->write(LogLevel::Error, "refusing to start '" + path + "'");
|
||||
return false;
|
||||
}
|
||||
@@ -300,61 +321,48 @@ bool Runtime::startApp(const std::string& path, const std::string& arg) {
|
||||
if (!open())
|
||||
return false;
|
||||
appPath_ = path;
|
||||
appTitle_ = appId();
|
||||
|
||||
const std::string directory = paths_.apps + "/" + path;
|
||||
installLoader(directory);
|
||||
if (!loadScript(directory + "/main.lua")) {
|
||||
providers_.log->write(LogLevel::Error, lua_tostring(state_, -1)
|
||||
? 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)) {
|
||||
installLoader();
|
||||
// The entry file runs first and start() mounts whatever it decides to, so an
|
||||
// app that fails either way leaves nothing behind.
|
||||
if (!loadMain(path) || !callStart(argsJson)) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Runtime::requestLaunch(const std::string& path, const std::string& arg,
|
||||
bool replace) {
|
||||
pending_.kind = replace ? Pending::Replace : Pending::Launch;
|
||||
pending_.route.path = path;
|
||||
pending_.route.arg = arg;
|
||||
bool Runtime::loadMain(const std::string& path) {
|
||||
if (!loadScript(path)) {
|
||||
providers_.log->write(LogLevel::Error, lua_tostring(state_, -1)
|
||||
? lua_tostring(state_, -1)
|
||||
: "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() {
|
||||
pending_.kind = Pending::Back;
|
||||
pending_.route = Route();
|
||||
void Runtime::requestStart(const std::string& path,
|
||||
const std::string& argsJson) {
|
||||
pending_.pending = true;
|
||||
pending_.path = path;
|
||||
pending_.argsJson = argsJson;
|
||||
}
|
||||
|
||||
bool Runtime::applyPendingNavigation() {
|
||||
const Pending pending = pending_;
|
||||
pending_ = Pending();
|
||||
if (pending.kind == Pending::None)
|
||||
if (!pending.pending)
|
||||
return hasApp();
|
||||
|
||||
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);
|
||||
return startApp(pending.path, pending.argsJson);
|
||||
}
|
||||
|
||||
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 {
|
||||
std::string tz = "UTC0";
|
||||
int32_t millis() const override { return 1234; }
|
||||
MemoryInfo memory() const override {
|
||||
const MemoryInfo info = {100, 200, 50};
|
||||
return info;
|
||||
}
|
||||
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 {
|
||||
@@ -148,7 +139,7 @@ struct Fs : FsProvider {
|
||||
struct Gui : GuiProvider {
|
||||
std::string trace;
|
||||
int32_t degrees = 0;
|
||||
bool fullscreen = false;
|
||||
std::string themeName = "light";
|
||||
bool gradient = false;
|
||||
|
||||
FontIds fonts() const override {
|
||||
@@ -158,11 +149,24 @@ struct Gui : GuiProvider {
|
||||
int32_t width() const override { return 320; }
|
||||
int32_t height() const override { return 240; }
|
||||
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 {
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
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 {
|
||||
trace += "fillRect;";
|
||||
}
|
||||
@@ -189,7 +193,6 @@ struct Gui : GuiProvider {
|
||||
trace += border ? ",border" : ",-";
|
||||
trace += ");";
|
||||
}
|
||||
void setFullscreen(bool on) override { fullscreen = on; }
|
||||
// Stands in for an e-ink panel, where a second commit is a second visible
|
||||
// refresh.
|
||||
void commit() override { commits++; }
|
||||
@@ -273,9 +276,10 @@ struct Wifi : WifiProvider {
|
||||
};
|
||||
|
||||
struct Ble : BleProvider {
|
||||
int32_t duration = 0;
|
||||
std::string value;
|
||||
bool initialized = false;
|
||||
bool observing = false;
|
||||
BleFilter filter;
|
||||
|
||||
Status init(const std::string*) override {
|
||||
initialized = true;
|
||||
@@ -283,12 +287,17 @@ struct Ble : BleProvider {
|
||||
}
|
||||
void deinit() override { initialized = false; }
|
||||
bool isInitialized() const override { return initialized; }
|
||||
Status scan(int32_t durationMs, std::vector<BleDevice>& devices) override {
|
||||
duration = durationMs;
|
||||
const BleDevice device = {"tag", "aa:bb", -60};
|
||||
devices.push_back(device);
|
||||
Status observe(const BleFilter& next) override {
|
||||
filter = next;
|
||||
observing = true;
|
||||
return Status::success();
|
||||
}
|
||||
void unobserve() override { observing = false; }
|
||||
bool isObserving() const override { return observing; }
|
||||
void observed(std::vector<BleObservation>& out) override {
|
||||
const BleObservation device = {"aa:bb", "tag", -60, "payload", 1234};
|
||||
out.push_back(device);
|
||||
}
|
||||
Status connect(const std::string&) override { return Status::success(); }
|
||||
void disconnect() override {}
|
||||
bool isConnected() const override { return true; }
|
||||
@@ -353,7 +362,6 @@ struct Buttons : ButtonsProvider {
|
||||
// Every provider a Runtime needs, so a test names only what it asserts on.
|
||||
struct Bench {
|
||||
Log log;
|
||||
Settings settings;
|
||||
Sys sys;
|
||||
Fs fs;
|
||||
Gui gui;
|
||||
@@ -367,7 +375,6 @@ struct Bench {
|
||||
Providers providers() {
|
||||
Providers providers;
|
||||
providers.log = &log;
|
||||
providers.settings = &settings;
|
||||
providers.sys = &sys;
|
||||
providers.fs = &fs;
|
||||
providers.gui = &gui;
|
||||
|
||||
+256
-124
@@ -37,13 +37,17 @@ int main() {
|
||||
assert(bench.log.level == esp32lua::LogLevel::Info);
|
||||
assert(bench.log.message == "shared runtime");
|
||||
|
||||
run(state, "assert(settings.getRotation() == 0)\n"
|
||||
"assert(settings.setRotation(90))\n"
|
||||
"assert(settings.getTimezone() == 'UTC0')\n"
|
||||
"assert(settings.setTimezone('EST5EDT'))");
|
||||
assert(bench.settings.degrees == 90);
|
||||
assert(bench.settings.tz == "EST5EDT");
|
||||
expectError(state, "settings.setRotation(45)");
|
||||
run(state, "assert(sys.hasFeature('screen'))\n"
|
||||
"assert(screen.getRotation() == 0)\n"
|
||||
"assert(screen.setRotation(90))\n"
|
||||
"assert(screen.getTheme() == 'light')\n"
|
||||
"assert(screen.setTheme('dark'))\n"
|
||||
"assert(sys.getTimezone() == 'UTC0')\n"
|
||||
"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"
|
||||
"assert(sys.hasFeature('touch') and sys.hasFeature('buttons'))\n"
|
||||
@@ -66,17 +70,17 @@ int main() {
|
||||
assert(bench.fs.written.size() == 3);
|
||||
expectError(state, "fs.readFile('/notes.txt', 999999)");
|
||||
|
||||
run(state, "assert(gui.getWidth() == 320 and gui.getHeight() == 240)\n"
|
||||
"assert(gui.FONT_UI == 2 and gui.STYLE_BOLD == 1)\n"
|
||||
"assert(gui.color(255, 0, 0) == 0xFF0000)\n"
|
||||
"gui.setRotation(180)\n"
|
||||
"gui.clear()\n"
|
||||
"gui.fillPolygon({1, 2, 3}, {4, 5, 6}, 0)\n"
|
||||
"gui.drawText(gui.FONT_UI, 0, 0, 'hi')");
|
||||
run(state, "assert(screen.getWidth() == 320 and screen.getHeight() == 240)\n"
|
||||
"assert(screen.FONT_UI == 2 and screen.STYLE_BOLD == 1)\n"
|
||||
"assert(screen.color(255, 0, 0) == 0xFF0000)\n"
|
||||
"assert(screen.setRotation(180))\n"
|
||||
"screen.clear()\n"
|
||||
"screen.fillPolygon({1, 2, 3}, {4, 5, 6}, 0)\n"
|
||||
"screen.drawText(screen.FONT_UI, 0, 0, 'hi')");
|
||||
assert(bench.gui.degrees == 180);
|
||||
assert(bench.gui.trace == "clear;fillPolygon3;drawText(hi);");
|
||||
expectError(state, "gui.fillPolygon({1, 2}, {3}, 0)");
|
||||
expectError(state, "gui.color(300, 0, 0)");
|
||||
expectError(state, "screen.fillPolygon({1, 2}, {3}, 0)");
|
||||
expectError(state, "screen.color(300, 0, 0)");
|
||||
|
||||
run(state, "local response = http.get('https://example.test', {maxBytes = "
|
||||
"16, headers = {Accept = 'text/plain'}})\n"
|
||||
@@ -89,6 +93,24 @@ int main() {
|
||||
bench.http.headers[0].name == "Accept");
|
||||
expectError(state, "http.get('https://example.test', {maxBytes = 999999})");
|
||||
|
||||
// cjson is required rather than global, because the library provides it and
|
||||
// no firmware implements it. Decoding is lua-cjson's; what is asserted here
|
||||
// is the wiring and the depth limit the panel's C stack needs.
|
||||
run(state,
|
||||
"local cjson = require 'cjson'\n"
|
||||
"assert(rawget(_G, 'cjson') == nil)\n"
|
||||
"local value = "
|
||||
"cjson.decode('{\"a\":[1,2.5,true],\"b\":\"x\\\\u00e9\"}')\n"
|
||||
"assert(math.type(value.a[1]) == 'integer' and value.a[2] == 2.5)\n"
|
||||
"assert(value.a[3] == true and value.b == 'x\\u{e9}')\n"
|
||||
"assert(cjson.decode('null') == cjson.null)\n"
|
||||
"assert(cjson.encode({1, 2, 3}) == '[1,2,3]')\n"
|
||||
"assert(not pcall(cjson.decode, '{'))\n"
|
||||
"assert(not pcall(cjson.encode, print))\n"
|
||||
"local deep = {}; for _ = 1, 40 do deep = {deep} end\n"
|
||||
"assert(not pcall(cjson.encode, deep))\n"
|
||||
"assert(not pcall(cjson.decode, string.rep('[', 40)))");
|
||||
|
||||
run(state, "assert(wifi.scan()[1].ssid == 'home')\n"
|
||||
"assert(wifi.isConnected())\n"
|
||||
"assert(wifi.getLocalIP() == '192.168.1.5')\n"
|
||||
@@ -99,10 +121,14 @@ int main() {
|
||||
run(state, "assert(not ble.isInitialized())\n"
|
||||
"assert(ble.init())\n"
|
||||
"assert(ble.isInitialized())\n"
|
||||
"assert(ble.scan()[1].address == 'aa:bb')\n"
|
||||
"assert(ble.observe({ services = { '181A' } }))\n"
|
||||
"assert(ble.isObserving())\n"
|
||||
"assert(ble.observed()[1].address == 'aa:bb')\n"
|
||||
"assert(ble.observed()[1].payload == 'payload')\n"
|
||||
"assert(#ble.read('svc', 'chr') == 3)\n"
|
||||
"assert(ble.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);
|
||||
|
||||
run(state, "fired = 0\n"
|
||||
@@ -120,92 +146,120 @@ int main() {
|
||||
expectError(state, "timer.after(0, function() end)");
|
||||
|
||||
run(state,
|
||||
"assert(settings.setCalibration(100, 200, 300, 400))\n"
|
||||
"local x, y = input.getTouch()\n"
|
||||
"assert(touch.setCalibration(100, 200, 300, 400))\n"
|
||||
"local x, y = touch.getPoint()\n"
|
||||
"assert(x == 10 and y == 20)\n"
|
||||
"assert(input.isTouched())\n"
|
||||
"assert(input.isPressed('confirm') and not input.isPressed('back'))\n"
|
||||
"assert(#input.getButtons() == 4 and input.getButtons()[3] == "
|
||||
"assert(touch.isTouched())\n"
|
||||
"assert(buttons.isPressed('confirm') and not buttons.isPressed('back'))\n"
|
||||
"assert(#buttons.getAll() == 4 and buttons.getAll()[3] == "
|
||||
"'confirm')");
|
||||
assert(bench.touch.calibration[3] == 400);
|
||||
|
||||
// Chrome control and gradients are core: an e-ink provider flattens what it
|
||||
// cannot show.
|
||||
run(state, "gui.setFullscreen(true)\n"
|
||||
"gui.roundRect(0, 0, 10, 10, 4, 0, 0xFF, 0x00)\n"
|
||||
"gui.roundRect(0, 0, 10, 10, 4, 0, nil, nil, 0xFF)");
|
||||
assert(bench.gui.fullscreen && bench.gui.gradient);
|
||||
// Gradients are core: an e-ink provider flattens what it cannot show.
|
||||
run(state, "screen.roundRect(0, 0, 10, 10, 4, 0, 0xFF, 0x00)\n"
|
||||
"screen.roundRect(0, 0, 10, 10, 4, 0, nil, nil, 0xFF)");
|
||||
assert(bench.gui.gradient);
|
||||
assert(bench.gui.trace.find("roundRect(-,border);") != std::string::npos);
|
||||
|
||||
bench.gui.trace.clear();
|
||||
run(state,
|
||||
"node.reset()\n"
|
||||
"local root = node.create(nil, {type = 'box', w = 'fill', h = 'fill', "
|
||||
"tree.reset()\n"
|
||||
"local root = tree.create(nil, {type = 'box', w = 'fill', h = 'fill', "
|
||||
"pad = 4, gap = 2})\n"
|
||||
"local label = node.create(root, {type = 'text', label = 'Hello'})\n"
|
||||
"local button = node.create(root, {type = 'button', h = 40, interactive "
|
||||
"local label = tree.create(root, {type = 'text', label = 'Hello'})\n"
|
||||
"local button = tree.create(root, {type = 'button', h = 40, interactive "
|
||||
"= true})\n"
|
||||
"node.setStyle(root, {background = 0xFFFFFF, fill = 0xFFFFFF, color = 0, "
|
||||
"tree.setStyle(root, {background = 0xFFFFFF, fill = 0xFFFFFF, color = 0, "
|
||||
"face = 0xEEEEEE,\n"
|
||||
" border = 0x333333, focusColor = 0xFF0000})\n"
|
||||
"assert(node.layout(root, 0, 0, 320, 240))\n"
|
||||
"local x, y, w, h = node.getRect(label)\n"
|
||||
"assert(tree.layout(root, 0, 0, 320, 240))\n"
|
||||
"local x, y, w, h = tree.getRect(label)\n"
|
||||
"assert(x == 4 and y == 4 and w == 312 and h == 16)\n"
|
||||
"assert(node.getLabel(label) == 'Hello')\n"
|
||||
"assert(node.hit(root, 10, 40) == button)\n"
|
||||
"assert(node.hit(root, 10, 200) == nil)\n"
|
||||
"assert(node.focusFirst(root) == button)\n"
|
||||
"assert(node.getFocus() == button)\n"
|
||||
"assert(node.moveFocus(root, 'up') == button)\n"
|
||||
"node.setPressed(button, true)\n"
|
||||
"assert(node.isPressed(button))\n"
|
||||
"assert(node.getCount() == 3 and node.getFootprint() > 0)\n"
|
||||
"node.draw(root)\n"
|
||||
"node.dropScratch()");
|
||||
"assert(tree.getLabel(label) == 'Hello')\n"
|
||||
"assert(tree.hit(root, 10, 40) == button)\n"
|
||||
// Hit is geometry: a point inside root but over no child answers root, not nil.
|
||||
"assert(tree.hit(root, 10, 200) == root)\n"
|
||||
"assert(tree.focusFirst(root) == button)\n"
|
||||
"assert(tree.getFocus() == button)\n"
|
||||
"assert(tree.moveFocus(root, 'up') == button)\n"
|
||||
"tree.setPressed(button, true)\n"
|
||||
"assert(tree.isPressed(button))\n"
|
||||
"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);
|
||||
// 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,-);") != std::string::npos);
|
||||
expectError(state, "node.create(nil, {type = 'nope'})");
|
||||
expectError(state, "tree.create(nil, {type = 'nope'})");
|
||||
|
||||
run(state,
|
||||
"node.reset()\n"
|
||||
"local root = node.create(nil, {type = 'custom', w = 'fill', h = "
|
||||
"tree.reset()\n"
|
||||
"local root = tree.create(nil, {type = 'custom', w = 'fill', h = "
|
||||
"'fill'})\n"
|
||||
"painted = 0\n"
|
||||
"node.setPainter(function(id, x, y, w, h) painted = painted + w end)\n"
|
||||
"assert(node.layout(root, 0, 0, 320, 240))\n"
|
||||
"node.draw(root)\n"
|
||||
"tree.setPainter(function(id, x, y, w, h) painted = painted + w end)\n"
|
||||
"assert(tree.layout(root, 0, 0, 320, 240))\n"
|
||||
"tree.draw(root)\n"
|
||||
"assert(painted == 320)");
|
||||
|
||||
// Callbacks: only init failing stops an app, and a release fires the tap
|
||||
// alias after the up.
|
||||
// A scrolling box: children measure past its edges, panning moves them and is
|
||||
// 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,
|
||||
"events = {}\n"
|
||||
"local function note(name) return function(a) events[#events + 1] = name "
|
||||
".. ':' .. tostring(a) end end\n"
|
||||
"function init(arg) events[#events + 1] = 'init:' .. tostring(arg) end\n"
|
||||
"function draw(delta) events[#events + 1] = 'draw:' .. delta end\n"
|
||||
"on_touch_down = note('down')\n"
|
||||
"on_touch_up = note('up')\n"
|
||||
"on_touch = note('tap')\n"
|
||||
"on_button_up = note('bup')\n"
|
||||
"on_button = note('btap')");
|
||||
bench.gui.commits = 0;
|
||||
assert(runtime.callInit("book.epub"));
|
||||
runtime.callDraw(33);
|
||||
runtime.callTouch(esp32lua::TouchPhase::Down, 5, 6);
|
||||
runtime.callTouch(esp32lua::TouchPhase::Move, 5,
|
||||
7); // the app defines no on_touch_move
|
||||
runtime.callTouch(esp32lua::TouchPhase::Up, 5, 8);
|
||||
runtime.callButton("confirm", false);
|
||||
run(state,
|
||||
"assert(table.concat(events, ' ') == "
|
||||
"'init:book.epub draw:33 down:5 up:5 tap:5 bup:confirm btap:confirm')");
|
||||
// One commit per visit to the app, so six calls and not seven: the release
|
||||
// and its tap alias are one visible change, and the move nobody handled still
|
||||
// ends a batch.
|
||||
assert(bench.gui.commits == 6);
|
||||
"tree.reset()\n"
|
||||
"local list = tree.create(nil, {type = 'box', w = 'fill', h = 'fill',\n"
|
||||
" scrollX = true, scrollY = true})\n"
|
||||
"local rows = {}\n"
|
||||
"for i = 1, 5 do rows[i] = tree.create(list, {type = 'box', w = 400, h = "
|
||||
"100, interactive = true}) end\n"
|
||||
"tree.setStyle(list, {background = 0xFFFFFF, fill = 0xFFFFFF, border = "
|
||||
"0x333333})\n"
|
||||
// The box is the panel; the content is deliberately larger on both axes.
|
||||
"assert(tree.layout(list, 0, 0, 320, 240))\n"
|
||||
"local maxX, maxY = tree.getScrollRange(list)\n"
|
||||
"assert(maxX == 400 - 320, 'content is wider than the box')\n"
|
||||
"assert(maxY == 5 * 100 - 240, 'content is taller than the box')\n"
|
||||
// Panning shifts the children, and getRect keeps answering screen space.
|
||||
"tree.setScroll(list, 30, 150)\n"
|
||||
"local x, y = tree.getRect(rows[1])\n"
|
||||
"assert(x == -30 and y == -150, 'row moved by the scroll')\n"
|
||||
"local sx, sy = tree.getScroll(list)\n"
|
||||
"assert(sx == 30 and sy == 150)\n"
|
||||
// The box itself does not move, only what it holds.
|
||||
"local bx, by = tree.getRect(list)\n"
|
||||
"assert(bx == 0 and by == 0)\n"
|
||||
// Row 1 is scrolled above the box, so a tap at the top hits row 2.
|
||||
"assert(tree.hit(list, 10, 10) == rows[2], 'scrolled-out row is not 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, {type = 'box', w = 'fill', h = 'fill', scrollY = true})\n"
|
||||
"local child = tree.create(bare, {type = 'box', w = 100, h = 900})\n"
|
||||
"assert(tree.layout(bare, 0, 0, 320, 240))\n"
|
||||
"assert(tree.hit(bare, 10, 10) == child, 'deepest node by geometry wins')\n"
|
||||
"assert(tree.hit(bare, 200, 10) == bare, 'the container answers where no child covers it')\n"
|
||||
"tree.dropScratch()\n"
|
||||
// Clamped, never past the content's far edge or before its start.
|
||||
"tree.setScroll(list, 9999, 9999)\n"
|
||||
"local cx, cy = tree.getScroll(list)\n"
|
||||
"assert(cx == maxX and cy == maxY, 'clamped to the content')\n"
|
||||
"tree.setScroll(list, -50, -50)\n"
|
||||
"local zx, zy = tree.getScroll(list)\n"
|
||||
"assert(zx == 0 and zy == 0, 'clamped at the origin')\n"
|
||||
"tree.draw(list)\n"
|
||||
"tree.dropScratch()");
|
||||
// The subtree is scissored to the box, so a row hanging past it cannot paint
|
||||
// over whatever sits outside.
|
||||
assert(bench.gui.trace.find("clip(0,0,320,240);") != std::string::npos);
|
||||
assert(bench.gui.trace.find("unclip;") != std::string::npos);
|
||||
|
||||
// A scrolled axis has no size to hand down, so the box needs one of its own.
|
||||
expectError(state, "tree.reset()\n"
|
||||
"local l = tree.create(nil, {type = 'box', scrollY = "
|
||||
"true})\n"
|
||||
"tree.create(l, {type = 'box', w = 10, h = 10})\n"
|
||||
"assert(tree.layout(l, 0, 0, 320, 240))");
|
||||
|
||||
// A timer firing inside draw is still one batch.
|
||||
run(state, "function draw() timer.after(1, function() end) end\n"
|
||||
@@ -214,12 +268,64 @@ int main() {
|
||||
runtime.callTimer(bench.timer.scheduled.back());
|
||||
assert(bench.gui.commits == 1);
|
||||
|
||||
run(state, "function init() error('boom') end");
|
||||
assert(!runtime.callInit(""));
|
||||
assert(bench.log.message.find("init: ") == 0);
|
||||
run(state, "function draw() error('kaboom') end");
|
||||
runtime.callDraw(1); // a failed frame logs and the app keeps running
|
||||
assert(bench.log.message.find("draw: ") == 0);
|
||||
// Callbacks land on the table main.lua returns, only start() failing stops an
|
||||
// app, and a release fires the tap alias after the up.
|
||||
{
|
||||
fake::Bench chrome;
|
||||
chrome.fs.files["/.lua/main.lua"] =
|
||||
"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
|
||||
// no-op.
|
||||
@@ -231,66 +337,92 @@ int main() {
|
||||
assert(noTouch.open());
|
||||
noTouch.callTouch(esp32lua::TouchPhase::Down, 1, 1);
|
||||
assert(headless.log.message == "callTouch without a touch provider");
|
||||
run(noTouch.state(),
|
||||
"assert(input.getTouch == nil and input.isPressed ~= nil)");
|
||||
run(noTouch.state(), "assert(touch == nil and buttons.isPressed ~= nil)");
|
||||
}
|
||||
|
||||
// App loading: a fresh state per app, require reaching the app directory and
|
||||
// /.lua/lib, and navigation applied between batches rather than inside a
|
||||
// A screenless firmware still runs: no gui provider, no screen/tree
|
||||
// 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.
|
||||
{
|
||||
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"] =
|
||||
"return {hello = function() return 'hi' end}";
|
||||
host.fs.files["/.lua/apps/Home/main.lua"] =
|
||||
"local greet = require('greet')\n"
|
||||
"function init(arg) started = greet.hello() .. ':' .. tostring(arg) "
|
||||
"end";
|
||||
"return {init = function(arg) started = greet.hello() .. ':' .. "
|
||||
"tostring(arg) end}";
|
||||
host.fs.files["/.lua/apps/Reader/main.lua"] =
|
||||
"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/Notes/main.lua"] = "function init() end";
|
||||
host.fs.files["/.lua/apps/Reader/Notes/main.lua"] =
|
||||
"return {init = function() end}";
|
||||
|
||||
esp32lua::Runtime app(host.providers());
|
||||
assert(app.startApp("Home"));
|
||||
assert(app.appId() == "Home" && app.appTitle() == "Home");
|
||||
assert(app.appDataPath() == "/.lua/data/Home");
|
||||
run(app.state(), "assert(started == 'hi:')");
|
||||
assert(app.startApp(esp32lua::MAIN_PATH));
|
||||
assert(app.appPath() == esp32lua::MAIN_PATH);
|
||||
run(app.state(), "assert(started == 'hi:nil' and route == 'Home')");
|
||||
|
||||
// A subapp shares the app ID, so both routes share one data directory.
|
||||
run(app.state(), "sys.launch('Reader', 'book.epub')");
|
||||
// The arguments cross the teardown as JSON, so a nested table survives and
|
||||
// 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());
|
||||
run(app.state(), "assert(started == 'hi:')"); // the current app keeps
|
||||
// running until applied
|
||||
run(app.state(), "assert(started == 'hi:nil')"); // still running until
|
||||
// applied
|
||||
assert(app.applyPendingNavigation());
|
||||
run(app.state(), "assert(started == 'page:book.epub')");
|
||||
run(app.state(), "sys.launch('Reader/Notes')");
|
||||
run(app.state(), "assert(started == 'page:book.epub')\n"
|
||||
"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.appPath() == "Reader/Notes" && app.appId() == "Reader");
|
||||
assert(app.appDataPath() == "/.lua/data/Reader");
|
||||
run(app.state(), "assert(route == 'Home' and #history == 0)");
|
||||
|
||||
// Back unwinds history, then lands on the launcher.
|
||||
run(app.state(), "sys.back()");
|
||||
assert(app.applyPendingNavigation() && app.appPath() == "Reader");
|
||||
run(app.state(), "sys.back()");
|
||||
assert(app.applyPendingNavigation() && app.appPath() == "Home");
|
||||
run(app.state(), "sys.back()");
|
||||
assert(app.applyPendingNavigation() && app.appPath() == "Home");
|
||||
// Arguments JSON cannot carry raise at the call, leaving the app running.
|
||||
expectError(app.state(), "sys.startApp('/.lua/main.lua', {f = print})");
|
||||
assert(!app.hasPendingNavigation());
|
||||
run(app.state(), "assert(route == 'Home')");
|
||||
expectError(app.state(), "sys.startApp('/.lua/main.lua', 'not a table')");
|
||||
|
||||
// sys.replace does not grow history, so back from it still reaches the
|
||||
// launcher.
|
||||
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"));
|
||||
// A missing file, a broken app, and a traversal all leave nothing running.
|
||||
assert(!app.startApp("/.lua/absent.lua"));
|
||||
assert(!app.hasApp() && app.state() == nullptr);
|
||||
host.fs.files["/.lua/apps/Broken/main.lua"] =
|
||||
"function init() error('nope') end";
|
||||
assert(!app.startApp("Broken"));
|
||||
"return {init = function() error('nope') end}";
|
||||
assert(!app.startApp(esp32lua::MAIN_PATH, "{\"app\":\"Broken\"}"));
|
||||
assert(!app.hasApp());
|
||||
assert(!app.startApp("../secrets"));
|
||||
assert(host.log.message.find("refusing to start") == 0);
|
||||
|
||||
+8
-4
@@ -35,7 +35,9 @@ def main():
|
||||
lib_dir = pathlib.Path(lib_dir)
|
||||
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 = []
|
||||
rows = []
|
||||
for src in sources:
|
||||
@@ -45,10 +47,12 @@ def main():
|
||||
subprocess.run([luac32, str(src), tmp_path], check=True)
|
||||
bytecode = pathlib.Path(tmp_path).read_bytes()
|
||||
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)
|
||||
arrays.append(f"static const unsigned char {name}[] = {{{comma}}};\n")
|
||||
rows.append(f' {{"{name}", {name}, sizeof({name})}},')
|
||||
arrays.append(f"static const unsigned char {ident}[] = {{{comma}}};\n")
|
||||
rows.append(f' {{"{name}", {ident}, sizeof({ident})}},')
|
||||
|
||||
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
|
||||
|
||||
+56
-15
@@ -7,13 +7,16 @@ table that registers them:
|
||||
// @lua-preamble ---@alias Feature "touch"|"lcd"
|
||||
// @lua-module sys SysLib creates the global
|
||||
// @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-field home? string a plain field of a @lua-app class
|
||||
// @lua-postamble -- notes rendered after the module
|
||||
|
||||
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
|
||||
runtime calls rather than registers.
|
||||
from the luaL_Reg entries below the directive, or from `// @lua-fn name` for the callbacks the
|
||||
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
|
||||
@@ -25,9 +28,10 @@ SOURCES = [ROOT / "native/src/bindings", ROOT / "native/src/runtime"]
|
||||
OUTPUT = ROOT / "lua/api"
|
||||
|
||||
MODULE = re.compile(r"^// @lua-(?P<kind>module|augment) (?P<name>\w+) (?P<class_>\w+)$")
|
||||
GLOBAL = re.compile(r"^// @lua-global(?: (?P<path>\S+))?$")
|
||||
FUNCTION = re.compile(r"^// @lua-fn (?P<name>\w+)$")
|
||||
APP = re.compile(r"^// @lua-app (?P<class_>\w+)(?: (?P<path>\S+))?$")
|
||||
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>.*))?$")
|
||||
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>.*)$")
|
||||
TABLE = re.compile(r"^\s*const luaL_Reg (?P<var>\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,
|
||||
"path": path,
|
||||
"consts": [],
|
||||
"fields": [],
|
||||
"preamble": [],
|
||||
"postamble": [],
|
||||
"functions": [],
|
||||
@@ -92,18 +97,19 @@ def parse(path):
|
||||
pending_module = new_module(module.group("kind"), module.group("name"), module.group("class_"))
|
||||
modules.append(pending_module)
|
||||
continue
|
||||
glob = GLOBAL.match(stripped)
|
||||
if glob:
|
||||
# Callbacks are globals an app defines, so they need no table and no class.
|
||||
pending_module = new_module("global", None, None, glob.group("path"))
|
||||
app = APP.match(stripped)
|
||||
if app:
|
||||
# Callbacks are fields of the table an app returns, so the block is a class
|
||||
# with no table of its own to register.
|
||||
pending_module = new_module("app", None, app.group("class_"), app.group("path"))
|
||||
modules.append(pending_module)
|
||||
current, in_table = pending_module, False
|
||||
doc = reset()
|
||||
continue
|
||||
function = FUNCTION.match(stripped)
|
||||
if function:
|
||||
if not current or current["kind"] != "global":
|
||||
raise SystemExit(f"{where}: @lua-fn outside a @lua-global block")
|
||||
if not current or current["kind"] != "app":
|
||||
raise SystemExit(f"{where}: @lua-fn outside a @lua-app block")
|
||||
if not doc["doc"]:
|
||||
raise SystemExit(f"{where}: {function.group('name')} has no description")
|
||||
current["functions"].append((function.group("name"), doc))
|
||||
@@ -120,6 +126,15 @@ def parse(path):
|
||||
)
|
||||
continuation = (target["consts"], len(target["consts"]) - 1, 3)
|
||||
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)
|
||||
if fix:
|
||||
if not target:
|
||||
@@ -135,8 +150,8 @@ def parse(path):
|
||||
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()
|
||||
continue
|
||||
# A @lua-global 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"):
|
||||
# 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"] == "app"):
|
||||
continue
|
||||
if stripped == "};":
|
||||
in_table = False
|
||||
@@ -169,13 +184,28 @@ def parse(path):
|
||||
doc = reset()
|
||||
|
||||
for module in modules:
|
||||
if module["kind"] == "global":
|
||||
if module["kind"] == "app":
|
||||
continue
|
||||
if not module["functions"]:
|
||||
raise SystemExit(f"{path.relative_to(ROOT)}: {module['name']} registers no annotated functions")
|
||||
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):
|
||||
lines = ["---@meta", "", f"-- Generated from {source.relative_to(ROOT)}. Do not edit.", ""]
|
||||
for module in modules:
|
||||
@@ -184,7 +214,18 @@ def render(source, modules):
|
||||
lines.extend(module["preamble"])
|
||||
if module["preamble"]:
|
||||
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']}")
|
||||
for const, type_, _, desc in module["consts"]:
|
||||
lines.append(f"---@field {const} {type_}{(' ' + desc) if desc else ''}")
|
||||
|
||||
+25
-1
@@ -3,7 +3,7 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from gen_api import ROOT, parse
|
||||
from gen_api import ROOT, parse, render
|
||||
|
||||
|
||||
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["params"][0][2] == "Absolute file path."
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user