Compare commits

26 Commits

Author SHA1 Message Date
evan cf1831bf93 feat(ble)!: stream observed advertisements 2026-08-08 13:58:19 -04:00
evan ddcdb52bb2 Revert "fix(layout): grow specs without contiguous reallocations"
This reverts commit eea6366ea2.
2026-08-08 13:30:17 -04:00
evan eea6366ea2 fix(layout): grow specs without contiguous reallocations 2026-08-08 13:27:52 -04:00
evan 94ff3c5ca5 fix(layout): grow styles without contiguous reallocations 2026-08-08 13:25:42 -04:00
evan 4568f56f92 Revert "fix(layout): reserve growable tree vectors up front to avoid bad_alloc"
This reverts commit 873e003c45.
2026-08-08 12:58:25 -04:00
evan 5d44234046 perf(ui): pass the label as an argument and share one empty spec
The label was the last field the wrapper wrote into a caller's spec, and on
a two-key text spec it was the key that forced a rehash. It now rides as an
argument like type, so ui.text writes nothing at all, and a button's padding
and centring are the tree's defaults rather than fields patched in from Lua.

With no builder writing into a spec any more, every spec-less call can share
one immutable table instead of allocating its own. It is frozen with a
__newindex that raises, because reintroducing a write would otherwise leak a
field into every spec-less node built afterwards -- a fault with no symptom
anywhere near its cause. One lua_State exists at a time, so the guard costs
about 100 bytes in total.

ui.spacer passes its spec through rather than copying w and h into a fresh
table, and the style alias on a text spec is dropped for textStyle, which no
caller used.

ui.rebuild now collects before the repaint rather than after it. The collect
was already there and its comment already named the hazard, but the painter
is the very next thing to want a large contiguous block for its band, and it
was being handed a heap still holding a screen's worth of dead spec tables --
a C++ allocation gets no emergency collection the way a failed Lua one does.
Worth 5.6kB of free heap at paint time; the largest block is unchanged,
because the freed specs are small and scattered.

Measured in the emulator, 12 sensors / 131 nodes: live Lua at build end
79.9kB -> 79.7kB and build time unchanged at 52ms. The raw heap figure looks
worse because removing the rehashes also removed the allocation pressure that
had been pacing the incremental collector, so the dead spec tables now sit
uncollected until something asks for them; live usage is what did not change.
ui.lua stripped bytecode 9499 -> 9340 bytes.
2026-08-08 12:19:53 -04:00
evan 8ceca622b9 perf(ui): stop writing into the caller's spec to build a node
A table constructor sizes its hash part to exactly the keys given, so every
field the wrapper added afterwards could rehash the spec -- twice for the
small ones. type and interactive are now arguments, which also keeps the
tree ignorant of what names handlers go by, and ui.label no longer writes w
and h: tree.create already measures the same text, font and style to size a
text node, so Lua was measuring the string twice to say what C derives.

Press styles are keyed by exception rather than one entry per node, which on
a full screen was an array part recording that almost nothing opts out.

Measured in the emulator, 12 sensors / 131 nodes, against the previous
commit: build 74ms -> 52ms, Lua transient +12.0kB -> +5.7kB, free heap at
build end 27936 -> 36140. Tree footprint is byte-identical throughout.

Two behaviour changes fall out. ui.label used to overwrite an explicit w
with the measured text width, so optionlist's fixed 10px marker gutter was
silently variable and is now the width it asks for. tree.create measured a
label in styleNormal while the painter draws it in the node's textStyle, so
a styled label was sized too narrow; it now measures in the style it paints.
2026-08-08 11:56:47 -04:00
evan 2104a91fc3 perf(ui): build nodes without per-node marshalling tables
build() copied a node's children into a second table so it could nil them
out of the spec, and applyStyle() copied the style keys into a third. Both
existed only to hand C a table it was already holding: tree.create reads
named fields and never touches the array part, so children come straight
off the spec, and create now applies the style itself. That is one C call
per styled node instead of two, and the sparse-style decision is made by
the set mask rather than by a loop over key names in Lua.

Measured in the emulator, 12 sensors / 131 nodes: build 106ms -> 74ms, Lua
transient +18.1kB -> +12.0kB, free heap at build end 18572 -> 27936. Tree
footprint is byte-identical, so the same styles are applied. ui.lua stripped
bytecode 10024 -> 9621 bytes, saved in every app state.

An explicit fill now wins over background, where the Lua version had
background clobber it; no caller sets both.
2026-08-08 11:31:06 -04:00
evan 5cec396697 perf(runtime): open only the stdlib libraries the Lua sources use
io, coroutine, utf8 and debug have zero call sites across every module and
app, and each one's tables and closures are live heap in every app's state.
Selective luaL_requiref replaces luaL_openlibs; os stays for one os.date.

Measured in the emulator: VM+stdlib baseline 24.4kB -> 21.3kB live.
2026-08-08 11:31:06 -04:00
evan 873e003c45 fix(layout): reserve growable tree vectors up front to avoid bad_alloc
Reserve nodes/specs/styles/labelAt/labels at reset() while the heap still
has a large contiguous block. A doubling realloc mid-build needs old+new
buffers live at once and throws bad_alloc on the heap the build itself has
fragmented; the styles vector was the one tripping the firmware's global
new-handler into a reset with ~10 populated cards.

UNVALIDATED: reproduced and fixed in the emulator only. Hardware has ~30KB
less free (NimBLE DMA buffers), so the reserves at reset() may still be
marginal there -- not yet tested on the board.
2026-08-08 08:49:54 -04:00
evan a09bcfaf06 feat(ble): replace blocking scan with advertisement observation
The one-shot scan kept only the top six devices by RSSI and dropped the
advertisement payload, so a distant beacon lost its slot to nearby phones
and its data was unreachable -- wrong on every axis for reading sensors
that broadcast in their adverts.

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

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

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

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

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

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

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

  ---@class PaintApp : App, TouchHandlers

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

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

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

Runtime::open() no longer requires a GuiProvider; a firmware without one runs
with no screen/tree globals and reports sys.hasFeature("screen") false.
Rotation is one value again: GuiProvider::setRotation applies and persists, so
an app rotating the panel transiently puts the old value back itself.
2026-08-05 10:26:38 -04:00
evan 445a9b2b8f fix(ui): collect after a rebuild so the heap is in a known state
A build allocates a spec table per node and drops them all at once, so an
app that scans WiFi right after a screen change met whatever the incremental
GC had got around to. Costs a few ms on a screen change; recovers ~24 KB.
2026-08-04 21:30:25 -04:00
evan 4b89b27947 feat(ui): size a grid to the app's box, not the panel
Chrome takes the top of the panel before an app builds anything, and layout
has not run yet when it does, so ui.frame() reports what the mount left it.
2026-08-04 21:15:21 -04:00
evan 25b576a3d9 refactor(gui): drop setFullscreen now that chrome is a Lua node
Fullscreen was the firmware surrendering a strip it clipped apps out of.
The strip is a sibling node now, so an app that wants the panel is chrome
choosing not to build itself.
2026-08-04 20:58:56 -04:00
evan 2fc3abc487 feat(sys): expose canGoBack for the chrome that offers the control 2026-08-04 20:55:16 -04:00
evan 3f26c4ff10 feat(ui): one mounted tree instead of a screen object per build
Chrome and the app now share a tree, so a screen is no longer something an
app constructs and holds: ui.mount() takes the function that builds the
whole thing and ui.rebuild() runs it again. Building a node after layout
is refused rather than silently resetting the arena under the panel.
2026-08-04 20:53:15 -04:00
evan e3153f57c5 feat(runtime): hand the whole app contract to /.lua/main.lua
The firmware knew four paths and called four globals, so the card could
not change its own layout or put anything around an app. It now loads one
file, and the table that file returns owns the rest: start() mounts the
route, home and data name the tree, and every callback is a field on it
rather than a global the app and its chrome would have to share.
2026-08-04 20:51:25 -04:00
evan e85adfa757 feat(settings): persist the theme through the settings binding
The palette lives in Lua, so C++ stores the name only and ui.setTheme()
writes through here before rebuilding and repainting.
2026-08-04 20:44:08 -04:00
47 changed files with 5110 additions and 1440 deletions
+20 -3
View File
@@ -4,11 +4,28 @@ This is a clean contract for repositories under the same owner's control. Choose
API without preserving old names, signatures, or behavior; consumers migrate to the contract.
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.
and reserve `bt` for a future Classic Bluetooth contract. `ble.observe(filter)` returns a reusable cursor iterator that retrieves one buffered advert at a time; runtime teardown ends the observation.
-174
View File
@@ -1,174 +0,0 @@
# Current API Landscape
Snapshot taken 2026-08-03 from the generated LuaLS stubs in Slate32 and CrossPoint Reader.
This inventory explains the gaps between the current firmwares and the contracts under
`lua/api/core/` and `lua/api/features/`.
## Summary
| | LCD / Slate32 | E-ink / CrossPoint |
|---|---:|---:|
| Declared functions | 77 | 67 |
| Names present in both | 27 | 27 |
| Same parameter list | 21 | 21 |
| Same name, different parameter list | 6 | 6 |
Matching declarations do not guarantee matching behavior. Important differences remain in
filesystem limits and ordering, TLS verification, network ownership, colors, and lifecycle.
## Original Common Ground
These names and parameter lists matched before the clean contract was defined. They are useful
implementation inventory, not constraints on `lua/api/core/`.
| Namespace | Functions | Remaining decisions |
|---|---|---|
| `http` | `get`, `head`, `delete`, `post`, `patch`, `download`, `urlencode` | The contract now returns `HttpResponse|nil, error`, requires a response `maxBytes`, and authenticates TLS. |
| `fs` | `exists`, `readFile`, `writeFile`, `listFiles`, `listDirs` | Standardize the read cap, sorted listings, path validation, and failure returns. |
| `log` | `debug`, `info`, `error` | Mostly ready. |
| `sys` | `delay`, `setTickInterval` | The clean contract drops both; callback timers replace periodic ticks and blocking delays. |
| `wifi` | `disconnect`, `isConnected` | Decide whether disconnect ownership is app-specific or device-wide. |
The basic `gui` drawing primitives are also intended for core. Colors will be opaque,
platform-native integers produced by `gui.color(r, g, b)`: RGB565 on LCD and quantized grayscale
on e-ink. Apps that need to be portable use `gui.color` rather than hard-coded values.
## Same Name, Different Signature
| Function | LCD | E-ink |
|---|---|---|
| `gui.clear` | `(color)` | `()` |
| `gui.drawLine` | `(x1, y1, x2, y2, color)` | `(x1, y1, x2, y2, width, color)` |
| `gui.drawText` | `(text, x, y, color, bg)` | `(font, x, y, text, color, style)` |
| `gui.fillCircle` | `(x, y, radius, color, bg)` | `(cx, cy, radius, color)` |
| `gui.getTextWidth` | `(text)` | `(font, text, style)` |
| `wifi.connect` | `(ssid, password)` | `()` using stored credentials |
These GUI differences are current implementation drift, not permanent display features. Wi-Fi
is also generic drift: both connection forms are useful, so the eventual shared signature can
make credentials optional after both firmwares implement both behaviors.
## Agreed GUI Direction
Basic geometry and text measurement belong in core with identical signatures:
```lua
gui.getWidth()
gui.getHeight()
gui.color(r, g, b)
gui.clear(color?)
gui.fillRect(x, y, w, h, color)
gui.drawRect(x, y, w, h, color)
gui.drawLine(x1, y1, x2, y2, color, width?)
gui.fillCircle(x, y, radius, color, background?)
gui.getTextWidth(font, text, style?)
gui.getFontHeight(font, style?)
gui.drawText(font, x, y, text, color?, style?, background?)
```
Fonts are opaque, zero-based integer IDs selected through shared semantic constants:
```lua
gui.FONT_SMALL
gui.FONT_UI
gui.FONT_BODY
gui.FONT_LARGE
```
Each implementation maps those roles to its available fonts or scales. Portable apps use the
named constants and metric functions rather than literal IDs or assumed pixel dimensions.
Physical names such as Bookerly and Noto Sans remain legacy implementation APIs, not part of
the core or e-ink feature contracts.
The optional background describes the surface behind anti-aliased output; it can have no visible
effect on a renderer without partial edge pixels. E-ink refresh policy and LCD live-frame
behavior remain feature-specific.
## Generic Features to Converge
These are not inherently LCD or e-ink concerns and should not be permanently assigned to a
display feature.
### Present only on E-ink
- Filesystem mutation and paging: `fileSize`, `mkdir`, `readLineAt`, `remove`, `removeTree`, `rename`.
These are intentionally under `lua/api/core/`; Slate32 must still implement them.
- Independent timers: `timer.after`, `timer.every`, `timer.cancel`
- `sys.uptime` / `sys.millis` naming
### Present only on LCD
- App routing: `sys.launch`, `sys.replace`, `sys.back`
- Runtime state: `sys.getAppName`, `setAppName`, `getMemory`, `isClockSynced`
- Wi-Fi management: `scan`, explicit credential connection, `forget`
- SD-backed `require` and app-local modules (runtime behavior, not a binding declaration)
- Native compact widget tree in `node.*`
The contract intentionally includes all of these generic capabilities. Both firmwares are under
our control, so they migrate to the clean API without compatibility aliases. The compact node
tree remains useful on e-ink for layout, painting, invalidation, and directional focus. Focus is
tree-level state with a deliberate appearance; moving it invalidates only the old and new nodes.
## Feature Contracts
- `buttons` guarantees physical-button polling/callbacks and button hints.
- `touch` guarantees calibrated/raw coordinates, touch callbacks, and calibration persistence.
A feature exists only where an app can call something a device may not have. Panel technology is
not such a case: firmware-owned publication and waveform policy already hold for every display, and
`gui.setFullscreen()` and `gui.roundRect()` are core, with a panel that has no gradient flattening
it exactly as `gui.color()` quantizes to grayscale. So the display is never a feature, and input is
never inferred from it. BLE/GATT remains
required core `ble`; a future `bt` namespace is reserved for Classic Bluetooth. Generic BMP,
polygon, shape, rotation, color, and text operations are core. Rotation and timezone settings are
also core, while theme persistence and application belong to shared `ui.lua`.
## Lifecycle Differences
| Concern | LCD | E-ink |
|---|---|---|
| Entry | required `init(arg)` | required `init()` |
| Navigation | Lua route stack | exit to C++ launcher |
| Input callbacks | touch down/move/up/tap | button callback and polling |
| Publication | drawing is immediately visible | apps currently call explicit refresh |
| Modules | app-local and shared `require` | effectively single-file apps |
The contract standardizes `init(arg?)`, navigation, and module loading. Optional `draw(deltaMs)`
runs once after initialization and then at most 30 FPS, best effort. The host passes monotonic
elapsed milliseconds, with zero on the first frame. Callback timers replace `on_tick`. Input
callbacks remain feature-specific. Firmware commits dirty content after callback batches and owns panel
refresh policy, including e-ink waveforms. `sys.getAPIVersion()` identifies the integer contract
version implemented by the firmware.
## Contract Rules
1. This is a clean API. Backward compatibility has no weight because all consumers are controlled
and migrate with the implementations.
2. A core function matches in name, parameters, returns, and observable behavior.
3. GUI coordinates use a top-left origin, including text; implementations translate driver
baselines and orientation details.
4. Invalid arguments raise Lua errors. Runtime failures return `nil, error`; filesystem reads
take an explicit `maxBytes` up to 64 KiB and fail rather than truncate. `fs.writeFile()` is
atomic: failure leaves the previous contents intact.
5. Apps live under `/.lua/apps`, shared modules under `/.lua/lib`, and persistent app data under
`/.lua/data/<AppId>`. Launch paths name directories relative to `apps`; firmware appends
`main.lua`. The first path component is the immutable app ID and determines `getAppDataPath()`.
6. `node` owns one focused ID outside its 16-byte nodes. Directional movement is geometric and
non-wrapping, with a distinct focused appearance.
7. `draw(deltaMs)` is the optional frame loop, capped at 30 FPS; the host supplies elapsed
monotonic milliseconds and callback-based `timer.after/every` handle periodic work.
8. Firmware commits dirty display content and owns panel refresh policy; no refresh API is
exposed to apps.
9. Apps are fully trusted; the contract adds no permissions or sandbox.
10. BLE/GATT is core `ble`; Classic Bluetooth remains undefined rather than sharing an inaccurate
namespace.
11. This repository owns portable modules under `lua/lib/`, including global theme behavior and the
declarative widget toolkit in `ui.lua`. Widgets expose only `on_enter`, `on_exit`, and
`on_click`: touch and directional focus map onto the same active-state lifecycle.
12. HTTPS authenticates certificates.
13. Generated firmware stubs are checked against this repository rather than copied here as
competing sources of truth.
14. Feature contracts contain only their declared hardware capability.
15. Runtime smoke tests cover semantics that LuaLS declarations cannot express.
+13 -4
View File
@@ -4,15 +4,19 @@ CC ?= cc
CXX ?= c++
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 $@
+37 -24
View File
@@ -4,9 +4,8 @@ A future shared contract for Lua applications running on the ESP32 firmwares in
`../slate32` and `../crosspoint-reader`.
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
View File
@@ -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"
]
}
}
+16 -8
View File
@@ -2,10 +2,11 @@
-- Generated from native/src/bindings/core/ble.cpp. Do not edit.
---@class BleDevice
---@field name string
---@field address string
---@field rssi integer
---@alias BleObservationIterator fun(state: nil, after: string|nil): address: string|nil, name: string|nil, rssi: integer|nil, payload: string|nil, lastSeenMs: integer|nil
---@class BleFilter
---@field services? string[] Service-data UUIDs to keep.
---@field manufacturers? integer[] Manufacturer ids to keep.
---@class BleLib
ble = {}
@@ -23,11 +24,18 @@ 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. The returned iterator is reusable; observation stops with its Lua state.
---@param filter? BleFilter Keep only matching adverts; nil keeps all.
---@return BleObservationIterator? observations
---@return string? error
function ble.scan(durationMs) end
function ble.observe(filter) end
---Stops observing and clears the buffered devices.
function ble.unobserve() end
---Whether advertisement observation is running.
---@return boolean
function ble.isObserving() end
---Connects to a peripheral.
---@param address string
-151
View File
@@ -1,151 +0,0 @@
---@meta
-- Generated from native/src/bindings/core/gui.cpp. Do not edit.
---@alias GuiColor integer
---@alias GuiFont integer
---@alias GuiTextStyle integer
---@class GuiLib
---@field FONT_SMALL GuiFont Small auxiliary text.
---@field FONT_UI GuiFont Normal controls and labels.
---@field FONT_BODY GuiFont Normal reading text.
---@field FONT_LARGE GuiFont Headings and prominent values.
---@field STYLE_NORMAL GuiTextStyle
---@field STYLE_BOLD GuiTextStyle
gui = {}
gui.FONT_SMALL = 0
gui.FONT_UI = 0
gui.FONT_BODY = 0
gui.FONT_LARGE = 0
gui.STYLE_NORMAL = 0
gui.STYLE_BOLD = 0
---Returns the live frame width.
---@return integer
function gui.getWidth() end
---Returns the live frame height.
---@return integer
function gui.getHeight() end
---Rotates the live frame without changing the saved preference.
---@param degrees integer 0, 90, 180, or 270 clockwise.
function gui.setRotation(degrees) end
---Returns the rotation of the live frame.
---@return integer Degrees clockwise for the live frame.
function gui.getRotation() end
---Returns an opaque native color. E-ink implementations quantize RGB to available grayscale.
---@param r integer 0 through 255.
---@param g integer 0 through 255.
---@param b integer 0 through 255.
---@return GuiColor
function gui.color(r, g, b) end
---Clears the frame.
---@param color? GuiColor Defaults to white.
function gui.clear(color) end
---Fills a rectangle.
---@param x integer
---@param y integer
---@param w integer
---@param h integer
---@param color GuiColor
function gui.fillRect(x, y, w, h, color) end
---Outlines a rectangle.
---@param x integer
---@param y integer
---@param w integer
---@param h integer
---@param color GuiColor
function gui.drawRect(x, y, w, h, color) end
---Draws a line.
---@param x1 integer
---@param y1 integer
---@param x2 integer
---@param y2 integer
---@param color GuiColor
---@param width? integer Defaults to one pixel.
function gui.drawLine(x1, y1, x2, y2, color, width) end
---Draws a single pixel.
---@param x integer
---@param y integer
---@param color GuiColor
function gui.drawPixel(x, y, color) end
---Outlines a circle.
---@param x integer Center.
---@param y integer Center.
---@param radius integer
---@param color GuiColor
---@param width? integer Defaults to one pixel.
function gui.drawCircle(x, y, radius, color, width) end
---Fills a circle.
---@param x integer Center.
---@param y integer Center.
---@param radius integer
---@param color GuiColor
---@param background? GuiColor Surface behind an anti-aliased edge.
function gui.fillCircle(x, y, radius, color, background) end
---Draws an anti-aliased rounded fill, optional gradient, and optional border in one pass.
---@param x integer
---@param y integer
---@param w integer
---@param h integer
---@param radius integer
---@param background GuiColor Surface behind the anti-aliased edge.
---@param top? GuiColor Fill, or gradient top; omitted for no fill.
---@param bottom? GuiColor Gradient bottom; defaults to top. Panels without a gradient use top.
---@param border? GuiColor Omitted for no border.
function gui.roundRect(x, y, w, h, radius, background, top, bottom, border) end
---Temporarily gives the app the full panel, including firmware chrome.
---@param on boolean
function gui.setFullscreen(on) end
---Fills a polygon.
---@param xs integer[]
---@param ys integer[]
---@param color GuiColor
function gui.fillPolygon(xs, ys, color) end
---Draws a bitmap.
---@param path string Absolute BMP path.
---@param x? integer Left edge; defaults to centered.
---@param y? integer Top edge; defaults to centered.
---@param maxWidth? integer Defaults to panel width.
---@param maxHeight? integer Defaults to panel height.
---@return true? ok
---@return string? error
function gui.drawBmp(path, x, y, maxWidth, maxHeight) end
---Measures a text run.
---@param font GuiFont Use a named gui.FONT_* role.
---@param text string
---@param style? GuiTextStyle Defaults to gui.STYLE_NORMAL.
---@return integer
function gui.getTextWidth(font, text, style) end
---Returns the line height of a font role.
---@param font GuiFont Use a named gui.FONT_* role.
---@param style? GuiTextStyle Defaults to gui.STYLE_NORMAL.
---@return integer
function gui.getFontHeight(font, style) end
---Draws a text run with its top-left corner at x, y.
---@param font GuiFont Use a named gui.FONT_* role.
---@param x integer Left edge.
---@param y integer Top edge.
---@param text string
---@param color? GuiColor Defaults to black.
---@param style? GuiTextStyle Defaults to gui.STYLE_NORMAL.
---@param background? GuiColor Omitted for transparent text.
function gui.drawText(font, x, y, text, color, style, background) end
+9 -13
View File
@@ -2,21 +2,17 @@
-- Generated from native/src/runtime/runtime.cpp. Do not edit.
-- 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.
-26
View File
@@ -1,26 +0,0 @@
---@meta
-- Generated from native/src/bindings/core/settings.cpp. Do not edit.
---@class SettingsLib
settings = {}
---Returns the saved rotation in degrees clockwise.
---@return integer
function settings.getRotation() end
---Applies and persists the screen rotation.
---@param degrees integer 0, 90, 180, or 270 clockwise.
---@return true? ok
---@return string? error
function settings.setRotation(degrees) end
---Returns the active POSIX timezone rule.
---@return string
function settings.getTimezone() end
---Applies and persists a POSIX timezone rule.
---@param timezone string
---@return true? ok
---@return string? error
function settings.setTimezone(timezone) end
+15 -29
View File
@@ -2,7 +2,7 @@
-- Generated from native/src/bindings/core/sys.cpp. Do not edit.
---@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
+15 -18
View File
@@ -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.
+164
View File
@@ -0,0 +1,164 @@
---@meta
-- Generated from native/src/bindings/features/screen/screen.cpp. Do not edit.
-- The panel itself; the widget tree it paints is `tree`, and
-- sys.hasFeature("screen") covers both.
---@alias ScreenColor integer
---@alias ScreenFont integer
---@alias ScreenTextStyle integer
---@class ScreenLib
---@field FONT_SMALL ScreenFont Small auxiliary text.
---@field FONT_UI ScreenFont Normal controls and labels.
---@field FONT_BODY ScreenFont Normal reading text.
---@field FONT_LARGE ScreenFont Headings and prominent values.
---@field STYLE_NORMAL ScreenTextStyle
---@field STYLE_BOLD ScreenTextStyle
screen = {}
screen.FONT_SMALL = 0
screen.FONT_UI = 0
screen.FONT_BODY = 0
screen.FONT_LARGE = 0
screen.STYLE_NORMAL = 0
screen.STYLE_BOLD = 0
---Returns the live frame width.
---@return integer
function screen.getWidth() end
---Returns the live frame height.
---@return integer
function screen.getHeight() end
---Rotates the panel and persists the choice, so there is one rotation
---rather than a live one and a saved one to reconcile.
---@param degrees integer 0, 90, 180, or 270 clockwise.
---@return true? ok
---@return string? error
function screen.setRotation(degrees) end
---Returns the rotation in degrees clockwise.
---@return integer
function screen.getRotation() end
---Returns the saved palette name. Apps read ui.getTheme() instead; this
---is the stored value, which only ui.setTheme() knows how to apply.
---@return string
function screen.getTheme() end
---Persists a palette name without applying it. Call ui.setTheme(), which
---writes through here and then rebuilds the palette and repaints.
---@param theme string
---@return true? ok
---@return string? error
function screen.setTheme(theme) end
---Returns an opaque native color. E-ink implementations quantize RGB to available grayscale.
---@param r integer 0 through 255.
---@param g integer 0 through 255.
---@param b integer 0 through 255.
---@return ScreenColor
function screen.color(r, g, b) end
---Clears the frame.
---@param color? ScreenColor Defaults to white.
function screen.clear(color) end
---Fills a rectangle.
---@param x integer
---@param y integer
---@param w integer
---@param h integer
---@param color ScreenColor
function screen.fillRect(x, y, w, h, color) end
---Outlines a rectangle.
---@param x integer
---@param y integer
---@param w integer
---@param h integer
---@param color ScreenColor
function screen.drawRect(x, y, w, h, color) end
---Draws a line.
---@param x1 integer
---@param y1 integer
---@param x2 integer
---@param y2 integer
---@param color ScreenColor
---@param width? integer Defaults to one pixel.
function screen.drawLine(x1, y1, x2, y2, color, width) end
---Draws a single pixel.
---@param x integer
---@param y integer
---@param color ScreenColor
function screen.drawPixel(x, y, color) end
---Outlines a circle.
---@param x integer Center.
---@param y integer Center.
---@param radius integer
---@param color ScreenColor
---@param width? integer Defaults to one pixel.
function screen.drawCircle(x, y, radius, color, width) end
---Fills a circle.
---@param x integer Center.
---@param y integer Center.
---@param radius integer
---@param color ScreenColor
---@param background? ScreenColor Surface behind an anti-aliased edge.
function screen.fillCircle(x, y, radius, color, background) end
---Draws an anti-aliased rounded fill, optional gradient, and optional border in one pass.
---@param x integer
---@param y integer
---@param w integer
---@param h integer
---@param radius integer
---@param background ScreenColor Surface behind the anti-aliased edge.
---@param top? ScreenColor Fill, or gradient top; omitted for no fill.
---@param bottom? ScreenColor Gradient bottom; defaults to top. Panels without a gradient use top.
---@param border? ScreenColor Omitted for no border.
function screen.roundRect(x, y, w, h, radius, background, top, bottom, border) end
---Fills a polygon.
---@param xs integer[]
---@param ys integer[]
---@param color ScreenColor
function screen.fillPolygon(xs, ys, color) end
---Draws a bitmap.
---@param path string Absolute BMP path.
---@param x? integer Left edge; defaults to centered.
---@param y? integer Top edge; defaults to centered.
---@param maxWidth? integer Defaults to panel width.
---@param maxHeight? integer Defaults to panel height.
---@return true? ok
---@return string? error
function screen.drawBmp(path, x, y, maxWidth, maxHeight) end
---Measures a text run.
---@param font ScreenFont Use a named screen.FONT_* role.
---@param text string
---@param style? ScreenTextStyle Defaults to screen.STYLE_NORMAL.
---@return integer
function screen.getTextWidth(font, text, style) end
---Returns the line height of a font role.
---@param font ScreenFont Use a named screen.FONT_* role.
---@param style? ScreenTextStyle Defaults to screen.STYLE_NORMAL.
---@return integer
function screen.getFontHeight(font, style) end
---Draws a text run with its top-left corner at x, y.
---@param font ScreenFont Use a named screen.FONT_* role.
---@param x integer Left edge.
---@param y integer Top edge.
---@param text string
---@param color? ScreenColor Defaults to black.
---@param style? ScreenTextStyle Defaults to screen.STYLE_NORMAL.
---@param background? ScreenColor Omitted for transparent text.
function screen.drawText(font, x, y, text, color, style, background) end
@@ -1,6 +1,6 @@
---@meta
-- 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
View File
@@ -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.
+27
View File
@@ -0,0 +1,27 @@
---@meta cjson
-- Hand-written, unlike the rest of lua/api: the implementation is lua-cjson under
-- native/src/vendor/cjson, so there are no binding annotations to generate from.
-- Depth is capped at 32 in native/src/bindings/lib/cjson.cpp, because decoding
-- recurses on the C stack and the upstream default of 1000 assumes a server.
--
-- Both functions raise on bad input rather than returning nil plus a message, so
-- a response from the network is worth a pcall.
---@class CJsonLib
local cjson = {}
---The value a JSON null decodes to, distinct from nil so a key survives it.
cjson.null = nil
---Parses JSON text.
---@param text string
---@return any value Tables, strings, numbers, booleans, or cjson.null.
function cjson.decode(text) end
---Serializes plain Lua data as JSON.
---@param value any Sequences become arrays; every other table becomes an object.
---@return string text
function cjson.encode(value) end
return cjson
+11 -11
View File
@@ -8,8 +8,8 @@ local ORDER = { "back", "left", "up", "down", "right", "confirm" }
local function available()
local 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
+348 -162
View File
@@ -12,10 +12,8 @@ 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
---@field fit? integer Maximum label width.
---@field background? GuiColor
---@field face? GuiColor
@@ -27,8 +25,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 +42,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 },
@@ -51,13 +50,35 @@ local THEMES = {
local enterHandlers, exitHandlers, clickHandlers, painters = {}, {}, {}, {}
-- A widget that paints its own press feedback opts out, so pressing one key does not
-- repaint the whole node the way a pressed style would.
local pressStyles = {}
-- repaint the whole node the way a pressed style would. Only the opt-outs are stored:
-- keyed the other way this held one entry per node, which on a full screen is a hash
-- part of several kB recording that almost nothing is an exception.
local noPressStyle = {}
local laidOut = false
local 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 +88,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,87 +134,76 @@ 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 = {}
noPressStyle = {}
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)
local STYLE_KEYS = {
"color",
"fill",
"border",
"face",
"pressedFace",
"pressedColor",
"focusColor",
"radius",
"font",
"textStyle",
}
-- Shared by every builder called without a spec. Safe only because nothing writes
-- into a spec any more: type, interactive and label are arguments, and a button's
-- padding is the tree's default rather than something patched in here. Frozen so
-- that reintroducing a write fails at the write instead of leaking a field into
-- every spec-less node built afterwards, which is a fault with no symptom near it.
local EMPTY = setmetatable({}, {
__newindex = function()
error("ui specs are read-only during a build", 2)
end,
})
local function applyStyle(id, spec)
local style, hasStyle = {}, false
for _, key in ipairs(STYLE_KEYS) do
if spec[key] ~= nil then
style[key], hasStyle = spec[key], true
end
end
if spec.background ~= nil then
style.background, style.fill, hasStyle = spec.background, spec.background, true
end
if hasStyle then
node.setStyle(id, style)
end
end
local function build(spec, kind)
spec = spec or {}
local function build(spec, kind, label)
spec = spec or EMPTY
-- 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 = {}
for index, child in ipairs(spec) do
children[index] = child
spec[index] = nil
-- Nothing is written into the spec: a table constructor sizes its hash part to
-- exactly the keys given, so adding type and interactive rehashed most specs --
-- twice for the small ones -- on the build path that peaks the heap. Children
-- come straight off the array part, which tree.create never reads.
local id = tree.create(nil, spec, kind,
spec.on_enter ~= nil or spec.on_exit ~= nil or spec.on_click ~= nil, label)
for _, child in ipairs(spec) do
tree.attach(id, child)
end
spec.type = kind
spec.interactive = spec.on_enter ~= nil or spec.on_exit ~= nil or spec.on_click ~= nil
local id = node.create(nil, spec)
for _, child in ipairs(children) do
node.attach(id, child)
end
applyStyle(id, spec)
enterHandlers[id] = spec.on_enter
exitHandlers[id] = spec.on_exit
clickHandlers[id] = spec.on_click
painters[id] = spec.paint
pressStyles[id] = spec.press_style ~= false
if spec.press_style == false then
noPressStyle[id] = true
end
if spec.scrollX or spec.scrollY then
scrollNodes[id] = { x = spec.scrollX == true, y = spec.scrollY == true }
scrollState[id] = { sx = 0, sy = 0, vx = 0, vy = 0 }
end
return id
end
@@ -212,59 +222,77 @@ 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)
spec = spec or {}
return build({ w = spec.w, h = spec.h }, "box")
-- Passed through rather than copied into a fresh {w, h}: tree.create reads what it
-- needs and ignores the rest.
return build(spec or EMPTY, "box")
end
---@param text string
---@param spec? UiSpec
---@return NodeId
function ui.text(text, spec)
spec = spec or {}
spec.label = text
spec.textStyle, spec.style = spec.style, nil
return build(spec, "text")
-- Writes nothing: the label rides as an argument, so a two-key text spec stays
-- two keys instead of rehashing to four.
return build(spec or EMPTY, "text", text)
end
---@param text string
---@param spec? UiSpec
---@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
text = text:sub(1, -2)
spec = spec or EMPTY
-- Sized by the intrinsic width tree.create measures from the same text, font and
-- style, so this measures only when the text has to be truncated. Writing w and h
-- here measured the string a second time and grew the spec by two keys, which on a
-- one-key spec is two rehashes.
if spec.fit then
local font = spec.font or screen.FONT_UI
local style = spec.style or screen.STYLE_NORMAL
if screen.getTextWidth(font, text, style) > spec.fit then
while #text > 1 and screen.getTextWidth(font, text .. "~", style) > spec.fit do
text = text:sub(1, -2)
end
text = text .. "~"
end
text = text .. "~"
end
spec.w = gui.getTextWidth(font, text, style)
spec.h = gui.getFontHeight(font, style)
spec.font, spec.fit = font, nil
return ui.text(text, spec)
end
---@param spec UiSpec
---@return NodeId
function ui.button(spec)
spec = spec or {}
spec.pad = spec.pad or 8
spec.align = spec.align or "center"
spec = spec or EMPTY
local label, font = spec.label, spec.font
spec.label = nil
local id = build(spec, "button")
if label then
node.create(id, { type = "text", label = label, font = font or gui.FONT_UI })
-- The button's own spec.label is not read by tree.create, so it needs no
-- clearing; the child text node carries the label instead.
tree.create(id, font and { font = font } or EMPTY, "text", false, label)
end
return id
end
@@ -278,16 +306,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 +367,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 +378,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 +397,134 @@ 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)
-- A build allocates a spec table per node and drops them all here, and the painter is
-- the very next thing to want a large contiguous block for its band. Collecting before
-- the repaint rather than after it sizes the band against the heap that exists, not one
-- still holding a screen's worth of dead spec tables -- a C++ allocation gets no
-- emergency collection the way a failed Lua one does. It costs a few milliseconds on a
-- screen change nobody can see, and leaves the heap in a known state instead of one that
-- depends on when the incremental GC last ran.
collectgarbage()
screen.clear(ui.theme.background)
tree.draw(root)
end
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)
if not noPressStyle[id] then
tree.setPressed(id, true)
end
local handler = enterHandlers[id]
if handler then
@@ -395,8 +533,8 @@ local function enter(id, x, y)
end
local function exit(id, x, y)
if pressStyles[id] then
node.setPressed(id, false)
if not noPressStyle[id] then
tree.setPressed(id, false)
end
local handler = exitHandlers[id]
if handler then
@@ -407,40 +545,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 +624,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 +669,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 +678,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 +700,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)
+275
View File
@@ -0,0 +1,275 @@
local ui = require "ui"
local FONT = screen.FONT_UI
local M = {}
local KEY_W, KEY_H, KEY_GAP, ROW_GAP = 26, 30, 4, 5
local SIDE_W, MODE_W, SPACE_W, OK_W = 40, 54, 174, 58
local PAGES = {
lower = { "qwertyuiop", "asdfghjkl", "zxcvbnm" },
upper = { "QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM" },
numbers = { "1234567890", "-/:;()$&@", ".,?!'" },
symbols = { "[]{}#%^*+=", "_\\|~<>$&@", ".,?!'" },
}
-- Keyed by node id, because the node itself is sixteen bytes in the firmware and holds
-- nothing a keyboard cares about.
local state = {}
local function chars(page, row)
return PAGES[page][row]
end
local function rowWidth(count)
return count * KEY_W + (count - 1) * KEY_GAP
end
local function centeredX(rect, width)
return rect.x + (rect.w - width) // 2
end
local function thirdRow(page, rect)
local value = chars(page, 3)
local width = SIDE_W * 2 + KEY_GAP * 2 + rowWidth(#value)
return value, centeredX(rect, width)
end
-- Pure geometry: which key covers a point, given a page and the rectangle the board was
-- placed in. Kept free of the node tree so the host tests can exercise the maths that
-- actually decides what a tap enters.
function M.keyAt(page, rect, x, y)
local localY = y - rect.y
if localY < 0 then
return
end
local row = localY // (KEY_H + ROW_GAP) + 1
if row > 4 or localY % (KEY_H + ROW_GAP) >= KEY_H then
return
end
if row <= 2 then
local value = chars(page, row)
local localX = x - centeredX(rect, rowWidth(#value))
if localX < 0 then
return
end
local column = localX // (KEY_W + KEY_GAP) + 1
if column <= #value and localX % (KEY_W + KEY_GAP) < KEY_W then
return (row - 1) * 10 + column, "char", value:sub(column, column)
end
return
end
if row == 3 then
local value, start = thirdRow(page, rect)
local localX = x - start
if localX < 0 then
return
end
if localX < SIDE_W then
return 90, (page == "lower" or page == "upper") and "shift" or "symbols"
end
localX = localX - SIDE_W - KEY_GAP
local width = rowWidth(#value)
if localX >= 0 and localX < width then
local column = localX // (KEY_W + KEY_GAP) + 1
if localX % (KEY_W + KEY_GAP) < KEY_W then
return 20 + column, "char", value:sub(column, column)
end
return
end
localX = localX - width - KEY_GAP
if localX >= 0 and localX < SIDE_W then
return 91, "backspace"
end
return
end
local width = MODE_W + SPACE_W + OK_W + KEY_GAP * 2
local localX = x - centeredX(rect, width)
if localX < 0 then
return
end
if localX < MODE_W then
return 100, "mode"
end
localX = localX - MODE_W - KEY_GAP
if localX >= 0 and localX < SPACE_W then
return 101, "space"
end
localX = localX - SPACE_W - KEY_GAP
if localX >= 0 and localX < OK_W then
return 102, "submit"
end
end
local function drawArrow(x, y, width, color, down)
local middle = x + width // 2
if down then
screen.drawLine(middle, y + 8, middle, y + 20, color)
screen.drawLine(middle - 5, y + 15, middle, y + 20, color)
screen.drawLine(middle, y + 20, middle + 5, y + 15, color)
else
screen.drawLine(middle, y + 9, middle, y + 21, color)
screen.drawLine(middle - 5, y + 14, middle, y + 9, color)
screen.drawLine(middle, y + 9, middle + 5, y + 14, color)
end
end
local function drawKey(id, label, x, y, width, pressed)
local theme = ui.theme
local face = pressed and theme.pressedFace or theme.face
local color = pressed and theme.pressedColor or theme.color
screen.roundRect(x, y, width, KEY_H, theme.radius, theme.background, face, nil, color)
if label == "shift" then
drawArrow(x, y, width, color, state[id].page == "upper")
else
screen.drawText(
FONT,
x + (width - screen.getTextWidth(FONT, label)) // 2,
y + (KEY_H - screen.getFontHeight(FONT)) // 2,
label,
color,
nil,
face
)
end
end
-- Walks every key, handing each one to `visit`. Painting the whole board and repainting a
-- single key are the same traversal, so a key's position is defined in one place. Pure,
-- for the same reason keyAt is.
function M.eachKey(page, rect, visit)
local y = rect.y
for row = 1, 2 do
local value = chars(page, row)
local x = centeredX(rect, rowWidth(#value))
for column = 1, #value do
visit((row - 1) * 10 + column, value:sub(column, column), x, y, KEY_W)
x = x + KEY_W + KEY_GAP
end
y = y + KEY_H + ROW_GAP
end
local value, x = thirdRow(page, rect)
visit(90, (page == "lower" or page == "upper") and "shift" or (page == "numbers" and "#+=" or "123"), x, y, SIDE_W)
x = x + SIDE_W + KEY_GAP
for column = 1, #value do
visit(20 + column, value:sub(column, column), x, y, KEY_W)
x = x + KEY_W + KEY_GAP
end
visit(91, "<-", x, y, SIDE_W)
y = y + KEY_H + ROW_GAP
local width = MODE_W + SPACE_W + OK_W + KEY_GAP * 2
x = centeredX(rect, width)
visit(100, (page == "lower" or page == "upper") and "123" or "ABC", x, y, MODE_W)
x = x + MODE_W + KEY_GAP
visit(101, "space", x, y, SPACE_W)
x = x + SPACE_W + KEY_GAP
visit(102, "OK", x, y, OK_W)
end
local function rectOf(id)
local x, y, w, h = tree.getRect(id)
return { x = x, y = y, w = w, h = h }
end
local function paint(id)
M.eachKey(state[id].page, rectOf(id), function(key, label, x, y, width)
drawKey(id, label, x, y, width, false)
end)
end
-- Repaints one key in place. The keyboard is a single node, so there is no parent to
-- clear and nothing else on screen can have moved.
local function drawOneKey(id, target, pressed)
M.eachKey(state[id].page, rectOf(id), function(key, label, x, y, width)
if key == target then
drawKey(id, label, x, y, width, pressed)
end
end)
end
local function down(id, x, y)
local key = M.keyAt(state[id].page, rectOf(id), x, y)
state[id].activeKey = key
if key then
drawOneKey(id, key, true)
end
end
-- The release repaints the key but keeps activeKey, because ui fires on_exit before
-- on_click and the click still has to know which key the press started on.
local function unpress(id)
local key = state[id].activeKey
if key then
drawOneKey(id, key, false)
end
end
local function press(id, x, y)
local st = state[id]
local key, action, char = M.keyAt(st.page, rectOf(id), x, y)
if not key or key ~= st.activeKey then
return
end
st.activeKey = nil
local function changed()
if st.on_change then
st.on_change(st.value)
end
end
if action == "char" then
if #st.value < st.max_length then
st.value = st.value .. char
changed()
end
elseif action == "shift" then
st.page = st.page == "lower" and "upper" or "lower"
tree.invalidate(id)
elseif action == "symbols" then
st.page = st.page == "numbers" and "symbols" or "numbers"
tree.invalidate(id)
elseif action == "mode" then
st.page = (st.page == "lower" or st.page == "upper") and "numbers" or "lower"
tree.invalidate(id)
elseif action == "backspace" then
st.value = st.value:sub(1, -2)
changed()
elseif action == "space" then
if #st.value < st.max_length then
st.value = st.value .. " "
changed()
end
elseif action == "submit" and st.on_submit then
st.on_submit(st.value)
end
end
-- One custom-painted node keeps a full keyboard off the heap: ordinary ui.button keys
-- would add dozens of nodes and their styles while wifi is already holding buffers.
function M.new(spec)
spec = spec or {}
local id = ui.custom {
h = 4 * KEY_H + 3 * ROW_GAP,
paint = paint,
on_enter = down,
on_click = press,
on_exit = unpress,
press_style = false, -- a key highlights itself; the node never does
}
state[id] = {
value = spec.value or "",
max_length = spec.max_length or 64,
page = "lower",
on_change = spec.on_change,
on_submit = spec.on_submit,
}
return id
end
return M
+95
View File
@@ -0,0 +1,95 @@
-- A message that slides up from the bottom edge, holds, then slides back down.
--
-- The card sits inside a scroll container of its own height whose content is twice that:
-- an empty strip above the card. Sliding is therefore ui.setScroll() on the tree already
-- laid out -- a delta on stored coordinates -- rather than a rebuild a frame, and the
-- container scissors the half of the card that has not arrived yet.
--
-- Whatever mounts the tree includes toast.node() and calls toast.draw(deltaMs); show()
-- rebuilds so the node exists, and hide() rebuilds to drop it.
local ui = require "ui"
local M = {}
local HEIGHT = 44
local MARGIN = 12
local SLIDE_MS = 180
local text, box
-- nil, or "in" / "hold" / "out" with the milliseconds spent in it.
local phase, elapsed, holdMs
---@param message string
---@param durationMs? integer How long the card holds before it slides away.
function M.show(message, durationMs)
text, holdMs = message, durationMs or 1500
phase, elapsed = "in", 0
ui.rebuild()
end
function M.hide()
if phase then
phase, text, box = nil, nil, nil
ui.rebuild()
end
end
---@return NodeId|nil
function M.node()
if not phase then
return nil
end
box = ui.box {
at = { x = MARGIN, y = screen.getHeight() - HEIGHT - MARGIN },
w = screen.getWidth() - 2 * MARGIN,
h = HEIGHT,
scrollY = true,
ui.box {
w = "fill",
ui.spacer { w = "fill", h = HEIGHT },
ui.box {
w = "fill",
h = HEIGHT,
pad = 12,
align = "center",
justify = "center",
background = ui.theme.face,
border = ui.theme.muted,
ui.text(text),
},
},
}
return box
end
local function progress()
if phase == "in" then
return elapsed / SLIDE_MS
elseif phase == "out" then
return 1 - elapsed / SLIDE_MS
end
return 1
end
---Advances the slide. Safe to call every frame whether or not a toast is up.
---@param deltaMs integer
function M.draw(deltaMs)
if not phase then
return
end
elapsed = elapsed + deltaMs
if phase == "in" and elapsed >= SLIDE_MS then
phase, elapsed = "hold", 0
elseif phase == "hold" and elapsed >= holdMs then
phase, elapsed = "out", 0
elseif phase == "out" and elapsed >= SLIDE_MS then
M.hide()
return
end
if box then
ui.setScroll(box, 0, math.floor(HEIGHT * progress()))
end
end
return M
+3 -3
View File
@@ -3,7 +3,7 @@ package.path = "./lua/lib/?.lua;" .. package.path
local drawn = {}
local 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,
}
+79 -48
View File
@@ -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,22 +67,22 @@ local function interactiveNodes()
return result
end
node = {
tree = {
reset = function()
nodes, focus, buttonCount = {}, nil, 0
end,
create = function(parent, spec)
create = function(parent, spec, kind, interactive, label)
local id = #nodes + 1
local x = 0
if spec.type == "button" then
if kind == "button" then
buttonCount = buttonCount + 1
x = (buttonCount - 1) * 100
end
nodes[id] = {
parent = parent,
type = spec.type,
label = spec.label,
interactive = spec.interactive,
type = kind,
label = label,
interactive = interactive or false,
rect = { x, 0, 90, 50 },
pressed = false,
style = {},
@@ -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"
+122 -14
View File
@@ -15,6 +15,7 @@
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <deque>
#include <utility>
#include <vector>
@@ -32,10 +33,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 +120,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 +156,7 @@ public:
labelAt.clear();
labels.clear();
styles.clear();
scrollState.clear();
error = nullptr;
}
@@ -192,8 +204,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 +224,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
@@ -301,7 +371,26 @@ public:
private:
std::vector<uint16_t> labelAt;
std::vector<char> labels;
std::vector<std::pair<uint16_t, Style>> styles;
std::deque<std::pair<uint16_t, Style>> styles;
// Linear rather than the sorted search styles use: a screen has one or two
// scroll containers, so the binary search would cost more than the scan.
std::vector<std::pair<uint16_t, Scroll>> scrollState;
Scroll* mutableScroll(uint16_t id) {
for (size_t at = 0; at < scrollState.size(); at++) {
if (scrollState[at].first == id)
return &scrollState[at].second;
}
scrollState.push_back(std::make_pair(id, Scroll()));
return &scrollState.back().second;
}
void shift(uint16_t id, int dx, int dy) {
nodes[id].x = static_cast<int16_t>(nodes[id].x + dx);
nodes[id].y = static_cast<int16_t>(nodes[id].y + dy);
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next)
shift(c, dx, dy);
}
void fail(const char* message) {
if (!error)
@@ -345,6 +434,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 +478,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) {
+60 -16
View File
@@ -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,11 @@ 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 bool nextObservation(const std::string* after,
BleObservation& out) = 0;
virtual Status connect(const std::string& address) = 0;
virtual void disconnect() = 0;
virtual bool isConnected() const = 0;
+43 -52
View File
@@ -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_;
};
+91 -21
View File
@@ -1,8 +1,11 @@
// @lua-module ble BleLib
// @lua-preamble ---@class BleDevice
// @lua-preamble ---@field name string
// @lua-preamble ---@field address string
// @lua-preamble ---@field rssi integer
// @lua-preamble ---@alias BleObservationIterator fun(state: nil, after:
// string|nil): address: string|nil, name: string|nil, rssi: integer|nil,
// payload: string|nil, lastSeenMs: integer|nil
// @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,23 +33,84 @@ 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);
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 nextObservation(lua_State* state) {
BleProvider& provider = Runtime::from(state)->ble();
if (!provider.isObserving())
return 0;
std::string after;
const std::string* cursor = nullptr;
if (!lua_isnoneornil(state, 2)) {
after = checkString(state, 2);
cursor = &after;
}
BleObservation observation;
if (!provider.nextObservation(cursor, observation))
return 0;
pushString(state, observation.address);
pushString(state, observation.name);
lua_pushinteger(state, observation.rssi);
pushString(state, observation.payload);
lua_pushinteger(state, observation.lastSeenMs);
return 5;
}
int observe(lua_State* state) {
BleProvider& provider = Runtime::from(state)->ble();
if (provider.isObserving())
return pushError(state, "already observing");
BleFilter filter;
if (lua_istable(state, 1)) {
readStringArray(state, 1, "services", filter.services);
readIntArray(state, 1, "manufacturers", filter.manufacturers);
}
const Status status = provider.observe(filter);
if (!status.ok)
return pushError(state, status.error);
lua_pushcfunction(state, nextObservation);
return 1;
}
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);
setField(state, "address", devices[at].address);
setField(state, "rssi", devices[at].rssi);
lua_rawseti(state, -2, static_cast<lua_Integer>(at + 1));
}
int unobserve(lua_State* state) {
Runtime::from(state)->ble().unobserve();
return 0;
}
int isObserving(lua_State* state) {
lua_pushboolean(state, Runtime::from(state)->ble().isObserving());
return 1;
}
@@ -108,11 +172,17 @@ 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.
// The returned iterator is reusable; observation stops with its Lua state.
// @param filter BleFilter|nil Keep only matching adverts; nil keeps all.
// @return BleObservationIterator|nil observations
// @return string|nil error
{"scan", scan},
{"observe", observe},
// --- Stops observing and clears the buffered devices.
{"unobserve", unobserve},
// --- Whether advertisement observation is running.
// @return boolean
{"isObserving", isObserving},
// --- Connects to a peripheral.
// @param address string
// @return true|nil ok
-78
View File
@@ -1,78 +0,0 @@
// @lua-module settings SettingsLib
#include <lua/runtime.h>
extern "C" {
#include "lauxlib.h"
#include "lua.h"
}
namespace esp32lua {
namespace bindings {
namespace {
int pushStatus(lua_State* state, const Status& status) {
if (status.ok) {
lua_pushboolean(state, true);
return 1;
}
lua_pushnil(state);
lua_pushlstring(state, status.error.data(), status.error.size());
return 2;
}
int getRotation(lua_State* state) {
lua_pushinteger(state, Runtime::from(state)->settings().rotation());
return 1;
}
int setRotation(lua_State* state) {
const lua_Integer degrees = luaL_checkinteger(state, 1);
luaL_argcheck(state, degrees >= 0 && degrees <= 270 && degrees % 90 == 0, 1,
"expected 0, 90, 180, or 270");
return pushStatus(state,
Runtime::from(state)->settings().setRotation(degrees));
}
int getTimezone(lua_State* state) {
const std::string timezone = Runtime::from(state)->settings().timezone();
lua_pushlstring(state, timezone.data(), timezone.size());
return 1;
}
int setTimezone(lua_State* state) {
size_t length = 0;
const char* value = luaL_checklstring(state, 1, &length);
return pushStatus(
state, Runtime::from(state)->settings().setTimezone({value, length}));
}
const luaL_Reg FUNCTIONS[] = {
// --- Returns the saved rotation in degrees clockwise.
// @return integer
{"getRotation", getRotation},
// --- Applies and persists the screen rotation.
// @param degrees integer 0, 90, 180, or 270 clockwise.
// @return true|nil ok
// @return string|nil error
{"setRotation", setRotation},
// --- Returns the active POSIX timezone rule.
// @return string
{"getTimezone", getTimezone},
// --- Applies and persists a POSIX timezone rule.
// @param timezone string
// @return true|nil ok
// @return string|nil error
{"setTimezone", setTimezone},
{nullptr, nullptr},
};
} // namespace
void registerSettings(lua_State* state) {
luaL_newlib(state, FUNCTIONS);
lua_setglobal(state, "settings");
}
} // namespace bindings
} // namespace esp32lua
+35 -52
View File
@@ -1,5 +1,5 @@
// @lua-module sys SysLib
// @lua-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},
};
+15 -10
View File
@@ -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 {
@@ -100,9 +99,10 @@ bool readFlag(lua_State* state, int index, const char* key) {
return value;
}
ui::Align readAlign(lua_State* state, int index, const char* key) {
ui::Align readAlign(lua_State* state, int index, const char* key,
ui::Align fallback = ui::START) {
lua_getfield(state, index, key);
ui::Align align = ui::START;
ui::Align align = fallback;
if (!lua_isnoneornil(state, -1)) {
const char* value = luaL_checkstring(state, -1);
if (strcmp(value, "center") == 0) {
@@ -122,9 +122,12 @@ ui::Align readAlign(lua_State* state, int index, const char* key) {
return align;
}
// Taken as arguments rather than spec fields: writing type and interactive into
// the caller's table rehashed it, because a constructor sizes the hash part to
// exactly the keys written and two more keys overflow it. Interactive is the
// caller's to decide, so the tree stays ignorant of what names handlers go by.
uint8_t readType(lua_State* state, int index) {
lua_getfield(state, index, "type");
const char* value = luaL_checkstring(state, -1);
const char* value = luaL_checkstring(state, index);
uint8_t type = ui::BOX;
if (strcmp(value, "text") == 0) {
type = ui::TEXT;
@@ -133,10 +136,8 @@ uint8_t readType(lua_State* state, int index) {
} else if (strcmp(value, "custom") == 0) {
type = ui::CUSTOM;
} else if (strcmp(value, "box") != 0) {
lua_pop(state, 1);
luaL_error(state, "unknown node type '%s'", value);
}
lua_pop(state, 1);
return type;
}
@@ -145,20 +146,67 @@ int reset(lua_State* state) {
return 0;
}
void readStyleColor(lua_State* state, int index, const char* key,
int32_t& field, uint16_t flag, uint16_t& set) {
lua_getfield(state, index, key);
if (!lua_isnoneornil(state, -1)) {
field = static_cast<int32_t>(luaL_checkinteger(state, -1));
set |= flag;
}
lua_pop(state, 1);
}
// Reads whatever style fields a spec carries, returning whether it named any. The
// Lua wrapper used to do this by copying the style keys into a second table and
// mapping background itself, which cost a table per styled node and a rehash of the
// caller's spec to express the mapping.
bool readStyleInto(lua_State* state, int index, ui::Style& style) {
const uint16_t before = style.set;
readStyleColor(state, index, "color", style.color, ui::S_COLOR, style.set);
readStyleColor(state, index, "background", style.bg, ui::S_BG, style.set);
readStyleColor(state, index, "fill", style.fill, ui::S_FILL, style.set);
readStyleColor(state, index, "border", style.border, ui::S_BORDER, style.set);
readStyleColor(state, index, "face", style.face, ui::S_FACE, style.set);
readStyleColor(state, index, "pressedFace", style.pressedFace,
ui::S_PRESSED_FACE, style.set);
readStyleColor(state, index, "pressedColor", style.pressedColor,
ui::S_PRESSED_COLOR, style.set);
readStyleColor(state, index, "focusColor", style.focusColor,
ui::S_FOCUS_COLOR, style.set);
readStyleColor(state, index, "font", style.font, ui::S_FONT, style.set);
readStyleColor(state, index, "textStyle", style.textStyle, ui::S_TEXT_STYLE,
style.set);
int32_t radius = style.radius;
readStyleColor(state, index, "radius", radius, ui::S_RADIUS, style.set);
style.radius = static_cast<uint8_t>(radius);
// A background is the surface its own corners blend into, so it implies fill.
// An explicit fill wins, which is the way round the Lua version had it backwards.
if ((style.set & ui::S_BG) && !(style.set & ui::S_FILL)) {
style.fill = style.bg;
style.set |= ui::S_FILL;
}
return style.set != before;
}
int create(lua_State* state) {
const bool hasParent = !lua_isnoneornil(state, 1);
const uint16_t parent = hasParent ? checkNode(state, 1) : ui::NONE;
luaL_checktype(state, 2, LUA_TTABLE);
const uint8_t type = readType(state, 2);
const uint8_t type = readType(state, 3);
ui::Spec spec;
bool present = false;
spec.w = readSize(state, 2, "w", present);
spec.h = readSize(state, 2, "h", present);
const int16_t pad = readNumber(state, 2, "pad", 0);
// A button pads and centres its label by default, which the Lua wrapper used to
// do by writing both into the caller's spec and rehashing it.
const bool button = type == ui::BUTTON;
const int16_t pad = readNumber(state, 2, "pad", button ? 8 : 0);
spec.padT = spec.padR = spec.padB = spec.padL = static_cast<uint8_t>(pad);
spec.gap = static_cast<uint8_t>(readNumber(state, 2, "gap", 0));
spec.align = readAlign(state, 2, "align");
spec.align = readAlign(state, 2, "align", button ? ui::CENTER : ui::START);
spec.justify = readAlign(state, 2, "justify");
lua_getfield(state, 2, "at");
@@ -173,10 +221,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"))
if (lua_toboolean(state, 4))
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)
@@ -184,12 +234,19 @@ int create(lua_State* state) {
: static_cast<int32_t>(luaL_checkinteger(state, -1));
lua_pop(state, 1);
lua_getfield(state, 2, "label");
// An argument for the same reason type is: a text node's spec is small, so the
// label was usually the key that pushed it into a rehash.
const char* label =
lua_isnoneornil(state, -1) ? nullptr : luaL_checkstring(state, -1);
lua_isnoneornil(state, 5) ? nullptr : luaL_checkstring(state, 5);
if (label && type == ui::TEXT) {
GuiProvider& gui = Runtime::from(state)->gui();
const int32_t style = gui.fonts().styleNormal;
// Measured with the style it will be painted in, not the normal one: a bold
// label is wider than its own box otherwise.
lua_getfield(state, 2, "textStyle");
const int32_t style = lua_isnoneornil(state, -1)
? gui.fonts().styleNormal
: static_cast<int32_t>(luaL_checkinteger(state, -1));
lua_pop(state, 1);
spec.intrinsicW = static_cast<int16_t>(gui.textWidth(font, label, style));
spec.intrinsicH = static_cast<int16_t>(gui.fontHeight(font, style));
}
@@ -197,7 +254,14 @@ int create(lua_State* state) {
const uint16_t id = tree(state).add(parent, spec, type, flags);
if (label)
tree(state).setLabel(id, label);
lua_pop(state, 1);
// Styled here rather than by a second call from Lua: the spec is already on the
// stack, and a node naming no colours must cost no Style entry, which is a
// decision the set mask makes better than a loop over key names in Lua.
ui::Style style;
if (readStyleInto(state, 2, style))
tree(state).styleFor(id) = style;
lua_pushinteger(state, id);
return 1;
}
@@ -270,6 +334,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);
@@ -294,39 +379,11 @@ int getParent(lua_State* state) {
return 1;
}
void readStyleColor(lua_State* state, int index, const char* key,
int32_t& field, uint16_t flag, uint16_t& set) {
lua_getfield(state, index, key);
if (!lua_isnoneornil(state, -1)) {
field = static_cast<int32_t>(luaL_checkinteger(state, -1));
set |= flag;
}
lua_pop(state, 1);
}
int setStyle(lua_State* state) {
const uint16_t id = checkNode(state, 1);
luaL_checktype(state, 2, LUA_TTABLE);
ui::Style& style = tree(state).styleFor(id);
readStyleColor(state, 2, "color", style.color, ui::S_COLOR, style.set);
readStyleColor(state, 2, "background", style.bg, ui::S_BG, style.set);
readStyleColor(state, 2, "fill", style.fill, ui::S_FILL, style.set);
readStyleColor(state, 2, "border", style.border, ui::S_BORDER, style.set);
readStyleColor(state, 2, "face", style.face, ui::S_FACE, style.set);
readStyleColor(state, 2, "pressedFace", style.pressedFace, ui::S_PRESSED_FACE,
style.set);
readStyleColor(state, 2, "pressedColor", style.pressedColor,
ui::S_PRESSED_COLOR, style.set);
readStyleColor(state, 2, "focusColor", style.focusColor, ui::S_FOCUS_COLOR,
style.set);
readStyleColor(state, 2, "font", style.font, ui::S_FONT, style.set);
readStyleColor(state, 2, "textStyle", style.textStyle, ui::S_TEXT_STYLE,
style.set);
int32_t radius = style.radius;
readStyleColor(state, 2, "radius", radius, ui::S_RADIUS, style.set);
style.radius = static_cast<uint8_t>(radius);
readStyleInto(state, 2, tree(state).styleFor(id));
tree(state).nodes[id].flags |= ui::DIRTY;
return 0;
@@ -467,7 +524,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 +537,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 +616,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 +674,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 +696,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
+27 -28
View File
@@ -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?
+90
View File
@@ -0,0 +1,90 @@
// JSON for apps, provided by lua-cjson under native/src/vendor/cjson. It
// decodes straight onto the Lua stack rather than building a document first, so
// a response costs its text plus the table it becomes and nothing in between.
#include <lua/runtime.h>
#include <string>
extern "C" {
#include "lauxlib.h"
#include "lua.h"
int luaopen_cjson(lua_State* state);
}
namespace esp32lua {
namespace bindings {
namespace {
// lua-cjson defaults to 1000 and decodes by recursing on the C stack, which is
// sized for a server rather than a FreeRTOS task. Setting it through the
// module's own knob keeps the vendored source unpatched.
constexpr lua_Integer MAX_DEPTH = 32;
void setDepth(lua_State* state, const char* name) {
lua_getfield(state, -1, name);
lua_pushinteger(state, MAX_DEPTH);
lua_call(state, 1, 0);
}
int open(lua_State* state) {
luaopen_cjson(state);
setDepth(state, "decode_max_depth");
setDepth(state, "encode_max_depth");
return 1;
}
} // namespace
// package.preload rather than a global: the library provides this itself, so no
// firmware implements it and nothing on the SD card shadows it. An app that
// never requires it never pays for the module.
void registerJson(lua_State* state) {
luaL_getsubtable(state, LUA_REGISTRYINDEX, LUA_PRELOAD_TABLE);
lua_pushcfunction(state, open);
lua_setfield(state, -2, "cjson");
lua_pop(state, 1);
}
} // namespace bindings
namespace {
// Leaves cjson.<name> on the stack. Going through require() rather than a
// second luaopen_cjson keeps one module, and one configured depth, per state.
void pushCjson(lua_State* state, const char* name) {
lua_getglobal(state, "require");
lua_pushstring(state, "cjson");
lua_call(state, 1, 1);
lua_getfield(state, -1, name);
lua_remove(state, -2);
}
} // namespace
std::string encodeJson(lua_State* state, int index) {
const int value = lua_absindex(state, index);
pushCjson(state, "encode");
lua_pushvalue(state, value);
lua_call(state, 1, 1);
size_t length = 0;
const char* text = lua_tolstring(state, -1, &length);
const std::string json(text ? text : "", length);
lua_pop(state, 1);
return json;
}
bool decodeJson(lua_State* state, const std::string& json) {
if (lua_gettop(state) + 4 > LUAI_MAXSTACK)
return false;
pushCjson(state, "decode");
lua_pushlstring(state, json.data(), json.size());
if (lua_pcall(state, 1, 1, 0) != LUA_OK) {
lua_pop(state, 1);
return false;
}
return true;
}
} // namespace esp32lua
File diff suppressed because one or more lines are too long
+220 -20
View File
@@ -1,10 +1,14 @@
#pragma once
// 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);
+3 -5
View File
@@ -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");
+121 -100
View File
@@ -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;
}
@@ -63,21 +65,35 @@ bool Runtime::open() {
if (!state_)
return false;
*static_cast<Runtime**>(lua_getextraspace(state_)) = this;
luaL_openlibs(state_);
// Only the libraries the Lua sources here actually reference. io, coroutine,
// utf8 and debug have zero call sites across every module and app, and each
// one's tables and closures are live heap in every app's state.
static const luaL_Reg kLibs[] = {
{LUA_GNAME, luaopen_base}, {LUA_LOADLIBNAME, luaopen_package},
{LUA_TABLIBNAME, luaopen_table}, {LUA_STRLIBNAME, luaopen_string},
{LUA_MATHLIBNAME, luaopen_math}, {LUA_OSLIBNAME, luaopen_os},
{nullptr, nullptr},
};
for (const luaL_Reg* lib = kLibs; lib->func; lib++) {
luaL_requiref(state_, lib->name, lib->func, 1);
lua_pop(state_, 1);
}
// Primary Namespaces
bindings::registerBle(state_);
bindings::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)
@@ -89,13 +105,12 @@ void Runtime::close() {
if (!state_)
return;
cancelAllTimers();
providers_.ble->unobserve();
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 +118,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 +156,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 +174,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 +219,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 +245,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 +314,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 +324,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 +334,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) {
+20
View File
@@ -0,0 +1,20 @@
Copyright (c) 2010-2012 Mark Pulford <mark@kyne.com.au>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+211
View File
@@ -0,0 +1,211 @@
/* fpconv - Floating point conversion routines
*
* Copyright (c) 2011-2012 Mark Pulford <mark@kyne.com.au>
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/* JSON uses a '.' decimal separator. strtod() / sprintf() under C libraries
* with locale support will break when the decimal separator is a comma.
*
* fpconv_* will around these issues with a translation buffer if required.
*/
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#include "fpconv.h"
/* Workaround for MSVC */
#ifdef _MSC_VER
#define inline __inline
#define snprintf sprintf_s
#endif
/* Lua CJSON assumes the locale is the same for all threads within a
* process and doesn't change after initialisation.
*
* This avoids the need for per thread storage or expensive checks
* for call. */
static char locale_decimal_point = '.';
/* In theory multibyte decimal_points are possible, but
* Lua CJSON only supports UTF-8 and known locales only have
* single byte decimal points ([.,]).
*
* localconv() may not be thread safe (=>crash), and nl_langinfo() is
* not supported on some platforms. Use sprintf() instead - if the
* locale does change, at least Lua CJSON won't crash. */
static void fpconv_update_locale(void)
{
char buf[8];
snprintf(buf, sizeof(buf), "%g", 0.5);
/* Failing this test might imply the platform has a buggy dtoa
* implementation or wide characters */
if (buf[0] != '0' || buf[2] != '5' || buf[3] != 0) {
fprintf(stderr, "Error: wide characters found or printf() bug.");
abort();
}
locale_decimal_point = buf[1];
}
/* Check for a valid number character: [-+0-9a-yA-Y.]
* Eg: -0.6e+5, infinity, 0xF0.F0pF0
*
* Used to find the probable end of a number. It doesn't matter if
* invalid characters are counted - strtod() will find the valid
* number if it exists. The risk is that slightly more memory might
* be allocated before a parse error occurs. */
static inline int valid_number_character(char ch)
{
char lower_ch;
if ('0' <= ch && ch <= '9')
return 1;
if (ch == '-' || ch == '+' || ch == '.')
return 1;
/* Hex digits, exponent (e), base (p), "infinity",.. */
lower_ch = ch | 0x20;
if ('a' <= lower_ch && lower_ch <= 'y')
return 1;
return 0;
}
/* Calculate the size of the buffer required for a strtod locale
* conversion. */
static int strtod_buffer_size(const char *s)
{
const char *p = s;
while (valid_number_character(*p))
p++;
return p - s;
}
/* Similar to strtod(), but must be passed the current locale's decimal point
* character. Guaranteed to be called at the start of any valid number in a string */
double fpconv_strtod(const char *nptr, char **endptr)
{
char localbuf[FPCONV_G_FMT_BUFSIZE];
char *buf, *endbuf, *dp;
int buflen;
double value;
/* System strtod() is fine when decimal point is '.' */
if (locale_decimal_point == '.')
return strtod(nptr, endptr);
buflen = strtod_buffer_size(nptr);
if (!buflen) {
/* No valid characters found, standard strtod() return */
*endptr = (char *)nptr;
return 0;
}
/* Duplicate number into buffer */
if (buflen >= FPCONV_G_FMT_BUFSIZE) {
/* Handle unusually large numbers */
buf = (char *)malloc(buflen + 1);
if (!buf) {
fprintf(stderr, "Out of memory");
abort();
}
} else {
/* This is the common case.. */
buf = localbuf;
}
memcpy(buf, nptr, buflen);
buf[buflen] = 0;
/* Update decimal point character if found */
dp = strchr(buf, '.');
if (dp)
*dp = locale_decimal_point;
value = strtod(buf, &endbuf);
*endptr = (char *)&nptr[endbuf - buf];
if (buflen >= FPCONV_G_FMT_BUFSIZE)
free(buf);
return value;
}
/* "fmt" must point to a buffer of at least 6 characters */
static void set_number_format(char *fmt, int precision)
{
int d1, d2, i;
assert(1 <= precision && precision <= 16);
/* Create printf format (%.14g) from precision */
d1 = precision / 10;
d2 = precision % 10;
fmt[0] = '%';
fmt[1] = '.';
i = 2;
if (d1) {
fmt[i++] = '0' + d1;
}
fmt[i++] = '0' + d2;
fmt[i++] = 'g';
fmt[i] = 0;
}
/* Assumes there is always at least 32 characters available in the target buffer */
int fpconv_g_fmt(char *str, double num, int precision)
{
char buf[FPCONV_G_FMT_BUFSIZE];
char fmt[6];
int len;
char *b;
set_number_format(fmt, precision);
/* Pass through when decimal point character is dot. */
if (locale_decimal_point == '.')
return snprintf(str, FPCONV_G_FMT_BUFSIZE, fmt, num);
/* snprintf() to a buffer then translate for other decimal point characters */
len = snprintf(buf, FPCONV_G_FMT_BUFSIZE, fmt, num);
/* Copy into target location. Translate decimal point if required */
b = buf;
do {
*str++ = (*b == locale_decimal_point ? '.' : *b);
} while(*b++);
return len;
}
void fpconv_init(void)
{
fpconv_update_locale();
}
/* vi:ai et sw=4 ts=4:
*/
+32
View File
@@ -0,0 +1,32 @@
/* Lua CJSON floating point conversion routines */
/* Buffer required to store the largest string representation of a double.
*
* Longest double printed with %.14g is 21 characters long:
* -1.7976931348623e+308 */
# define FPCONV_G_FMT_BUFSIZE 32
#ifdef USE_INTERNAL_FPCONV
#ifdef MULTIPLE_THREADS
#include "dtoa_config.h"
#include <unistd.h>
static inline void fpconv_init()
{
// Add one to try and avoid core id multiplier alignment
set_max_dtoa_threads((sysconf(_SC_NPROCESSORS_CONF) + 1) * 3);
}
#else
static inline void fpconv_init()
{
/* Do nothing - not required */
}
#endif
#else
extern void fpconv_init(void);
#endif
extern int fpconv_g_fmt(char*, double, int);
extern double fpconv_strtod(const char*, char**);
/* vi:ai et sw=4 ts=4:
*/
File diff suppressed because it is too large Load Diff
+199
View File
@@ -0,0 +1,199 @@
/* strbuf - String buffer routines
*
* Copyright (c) 2010-2012 Mark Pulford <mark@kyne.com.au>
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
#include <stdio.h>
#include <stdlib.h>
#include <stdarg.h>
#include <string.h>
#include <stdint.h>
#include "strbuf.h"
static void die(const char *fmt, ...)
{
va_list arg;
va_start(arg, fmt);
vfprintf(stderr, fmt, arg);
va_end(arg);
fprintf(stderr, "\n");
abort();
}
void strbuf_init(strbuf_t *s, size_t len)
{
size_t size;
if (!len)
size = STRBUF_DEFAULT_SIZE;
else
size = len + 1;
if (size < len)
die("Overflow, len: %zu", len);
s->buf = NULL;
s->size = size;
s->length = 0;
s->dynamic = 0;
s->reallocs = 0;
s->debug = 0;
s->buf = (char *)malloc(size);
if (!s->buf)
die("Out of memory");
strbuf_ensure_null(s);
}
strbuf_t *strbuf_new(size_t len)
{
strbuf_t *s;
s = (strbuf_t*)malloc(sizeof(strbuf_t));
if (!s)
die("Out of memory");
strbuf_init(s, len);
/* Dynamic strbuf allocation / deallocation */
s->dynamic = 1;
return s;
}
static inline void debug_stats(strbuf_t *s)
{
if (s->debug) {
fprintf(stderr, "strbuf(%p) reallocs: %d, length: %zd, size: %zd\n",
(void *) s, s->reallocs, s->length, s->size);
}
}
/* If strbuf_t has not been dynamically allocated, strbuf_free() can
* be called any number of times strbuf_init() */
void strbuf_free(strbuf_t *s)
{
debug_stats(s);
if (s->buf) {
free(s->buf);
s->buf = NULL;
}
if (s->dynamic)
free(s);
}
char *strbuf_free_to_string(strbuf_t *s, size_t *len)
{
char *buf;
debug_stats(s);
strbuf_ensure_null(s);
buf = s->buf;
if (len)
*len = s->length;
if (s->dynamic)
free(s);
return buf;
}
static size_t calculate_new_size(strbuf_t *s, size_t len)
{
size_t reqsize, newsize;
if (len <= 0)
die("BUG: Invalid strbuf length requested");
/* Ensure there is room for optional NULL termination */
reqsize = len + 1;
if (reqsize < len)
die("Overflow, len: %zu", len);
/* If the user has requested to shrink the buffer, do it exactly */
if (s->size > reqsize)
return reqsize;
newsize = s->size;
if (reqsize >= SIZE_MAX / 2) {
newsize = reqsize;
} else {
/* Exponential sizing */
while (newsize < reqsize)
newsize *= 2;
}
if (newsize < reqsize)
die("BUG: strbuf length would overflow, len: %zu", len);
return newsize;
}
/* Ensure strbuf can handle a string length bytes long (ignoring NULL
* optional termination). */
void strbuf_resize(strbuf_t *s, size_t len)
{
size_t newsize;
newsize = calculate_new_size(s, len);
if (s->debug > 1) {
fprintf(stderr, "strbuf(%p) resize: %zd => %zd\n",
(void *) s, s->size, newsize);
}
s->size = newsize;
s->buf = (char *)realloc(s->buf, s->size);
if (!s->buf)
die("Out of memory, len: %zu", len);
s->reallocs++;
}
void strbuf_append_string(strbuf_t *s, const char *str)
{
int i;
size_t space;
space = strbuf_empty_length(s);
for (i = 0; str[i]; i++) {
if (space < 1) {
strbuf_resize(s, s->length + 1);
space = strbuf_empty_length(s);
}
s->buf[s->length] = str[i];
s->length++;
space--;
}
}
/* vi:ai et sw=4 ts=4:
*/
+157
View File
@@ -0,0 +1,157 @@
/* strbuf - String buffer routines
*
* Copyright (c) 2010-2012 Mark Pulford <mark@kyne.com.au>
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
#include <stdlib.h>
#include <stdarg.h>
/* Workaround for MSVC */
#ifdef _MSC_VER
#define inline __inline
#endif
/* Size: Total bytes allocated to *buf
* Length: String length, excluding optional NULL terminator.
* Dynamic: True if created via strbuf_new()
*/
typedef struct {
char *buf;
size_t size;
size_t length;
int dynamic;
int reallocs;
int debug;
} strbuf_t;
#ifndef STRBUF_DEFAULT_SIZE
#define STRBUF_DEFAULT_SIZE 1023
#endif
/* Initialise */
extern strbuf_t *strbuf_new(size_t len);
extern void strbuf_init(strbuf_t *s, size_t len);
/* Release */
extern void strbuf_free(strbuf_t *s);
extern char *strbuf_free_to_string(strbuf_t *s, size_t *len);
/* Management */
extern void strbuf_resize(strbuf_t *s, size_t len);
static size_t strbuf_empty_length(strbuf_t *s);
static size_t strbuf_length(strbuf_t *s);
static char *strbuf_string(strbuf_t *s, size_t *len);
static void strbuf_ensure_empty_length(strbuf_t *s, size_t len);
static char *strbuf_empty_ptr(strbuf_t *s);
static void strbuf_extend_length(strbuf_t *s, size_t len);
static void strbuf_set_length(strbuf_t *s, int len);
/* Update */
static void strbuf_append_mem(strbuf_t *s, const char *c, size_t len);
extern void strbuf_append_string(strbuf_t *s, const char *str);
static void strbuf_append_char(strbuf_t *s, const char c);
static void strbuf_ensure_null(strbuf_t *s);
/* Reset string for before use */
static inline void strbuf_reset(strbuf_t *s)
{
s->length = 0;
}
static inline int strbuf_allocated(strbuf_t *s)
{
return s->buf != NULL;
}
/* Return bytes remaining in the string buffer
* Ensure there is space for a NULL terminator. */
static inline size_t strbuf_empty_length(strbuf_t *s)
{
return s->size - s->length - 1;
}
static inline void strbuf_ensure_empty_length(strbuf_t *s, size_t len)
{
if (len > strbuf_empty_length(s))
strbuf_resize(s, s->length + len);
}
static inline char *strbuf_empty_ptr(strbuf_t *s)
{
return s->buf + s->length;
}
static inline void strbuf_set_length(strbuf_t *s, int len)
{
s->length = len;
}
static inline void strbuf_extend_length(strbuf_t *s, size_t len)
{
s->length += len;
}
static inline size_t strbuf_length(strbuf_t *s)
{
return s->length;
}
static inline void strbuf_append_char(strbuf_t *s, const char c)
{
strbuf_ensure_empty_length(s, 1);
s->buf[s->length++] = c;
}
static inline void strbuf_append_char_unsafe(strbuf_t *s, const char c)
{
s->buf[s->length++] = c;
}
static inline void strbuf_append_mem(strbuf_t *s, const char *c, size_t len)
{
strbuf_ensure_empty_length(s, len);
memcpy(s->buf + s->length, c, len);
s->length += len;
}
static inline void strbuf_append_mem_unsafe(strbuf_t *s, const char *c, size_t len)
{
memcpy(s->buf + s->length, c, len);
s->length += len;
}
static inline void strbuf_ensure_null(strbuf_t *s)
{
s->buf[s->length] = 0;
}
static inline char *strbuf_string(strbuf_t *s, size_t *len)
{
if (len)
*len = s->length;
return s->buf;
}
/* vi:ai et sw=4 ts=4:
*/
+39 -25
View File
@@ -23,28 +23,19 @@ struct Log : LogProvider {
}
};
struct Settings : SettingsProvider {
int32_t degrees = 0;
std::string tz = "UTC0";
int32_t rotation() const override { return degrees; }
Status setRotation(int32_t value) override {
degrees = value;
return Status::success();
}
std::string timezone() const override { return tz; }
Status setTimezone(const std::string& value) override {
tz = value;
return Status::success();
}
};
struct Sys : SysProvider {
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,24 @@ 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; }
bool nextObservation(const std::string* after, BleObservation& out) override {
if (!after) {
out = {"aa:bb", "tag", -60, "payload", 1234};
return true;
}
if (*after == "aa:bb") {
out = {"cc:dd", "tag 2", -70, "payload 2", 2345};
return true;
}
return false;
}
Status connect(const std::string&) override { return Status::success(); }
void disconnect() override {}
bool isConnected() const override { return true; }
@@ -353,7 +369,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 +382,6 @@ struct Bench {
Providers providers() {
Providers providers;
providers.log = &log;
providers.settings = &settings;
providers.sys = &sys;
providers.fs = &fs;
providers.gui = &gui;
+281 -127
View File
@@ -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,32 @@ 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"
"observations = assert(ble.observe({ services = { '181A' } }))\n"
"assert(ble.isObserving())\n"
"local function checkObservations()\n"
" local count = 0\n"
" for address, name, rssi, payload, lastSeenMs in observations do\n"
" count = count + 1\n"
" if address == 'aa:bb' then\n"
" assert(name == 'tag' and rssi == -60)\n"
" assert(payload == 'payload' and lastSeenMs == 1234)\n"
" else\n"
" assert(address == 'cc:dd' and name == 'tag 2' and rssi == -70)\n"
" assert(payload == 'payload 2' and lastSeenMs == 2345)\n"
" end\n"
" end\n"
" assert(count == 2)\n"
"end\n"
"checkObservations()\n"
"checkObservations()\n"
"local duplicate, err = ble.observe()\n"
"assert(duplicate == nil and err == 'already observing')\n"
"ble.unobserve()\n"
"assert(not ble.isObserving())\n"
"assert(#ble.read('svc', 'chr') == 3)\n"
"assert(ble.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 +164,119 @@ 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', "
"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 "
"= true})\n"
"node.setStyle(root, {background = 0xFFFFFF, fill = 0xFFFFFF, color = 0, "
"tree.reset()\n"
"local root = tree.create(nil, {w = 'fill', h = 'fill', "
"pad = 4, gap = 2}, 'box')\n"
"local label = tree.create(root, {}, 'text', false, 'Hello')\n"
"local button = tree.create(root, {h = 40}, 'button', true)\n"
"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, {}, 'nope')");
run(state,
"node.reset()\n"
"local root = node.create(nil, {type = 'custom', w = 'fill', h = "
"'fill'})\n"
"tree.reset()\n"
"local root = tree.create(nil, {w = 'fill', h = "
"'fill'}, 'custom')\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, {w = 'fill', h = 'fill',\n"
" scrollX = true, scrollY = true}, 'box')\n"
"local rows = {}\n"
"for i = 1, 5 do rows[i] = tree.create(list, {w = 400, h = "
"100}, 'box', 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, {w = 'fill', h = 'fill', scrollY = true}, 'box')\n"
"local child = tree.create(bare, {w = 100, h = 900}, 'box')\n"
"assert(tree.layout(bare, 0, 0, 320, 240))\n"
"assert(tree.hit(bare, 10, 10) == child, 'deepest node by geometry wins')\n"
"assert(tree.hit(bare, 200, 10) == bare, 'the container answers where no child covers it')\n"
"tree.dropScratch()\n"
// Clamped, never past the content's far edge or before its start.
"tree.setScroll(list, 9999, 9999)\n"
"local cx, cy = tree.getScroll(list)\n"
"assert(cx == maxX and cy == maxY, 'clamped to the content')\n"
"tree.setScroll(list, -50, -50)\n"
"local zx, zy = tree.getScroll(list)\n"
"assert(zx == 0 and zy == 0, 'clamped at the origin')\n"
"tree.draw(list)\n"
"tree.dropScratch()");
// The subtree is scissored to the box, so a row hanging past it cannot paint
// over whatever sits outside.
assert(bench.gui.trace.find("clip(0,0,320,240);") != std::string::npos);
assert(bench.gui.trace.find("unclip;") != std::string::npos);
// A scrolled axis has no size to hand down, so the box needs one of its own.
expectError(state, "tree.reset()\n"
"local l = tree.create(nil, {scrollY = "
"true}, 'box')\n"
"tree.create(l, {w = 10, h = 10}, 'box')\n"
"assert(tree.layout(l, 0, 0, 320, 240))");
// A timer firing inside draw is still one batch.
run(state, "function draw() timer.after(1, function() end) end\n"
@@ -214,12 +285,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,71 +354,102 @@ 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);
}
run(state, "observations = assert(ble.observe())");
assert(bench.ble.observing);
runtime.close();
assert(!bench.ble.observing);
// Missing core providers leave no Lua state behind.
esp32lua::Providers incomplete = bench.providers();
incomplete.fs = nullptr;
+8 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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")