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.
This commit is contained in:
2026-08-05 10:42:01 -04:00
parent 75b3a2c490
commit b9f7c9347c
11 changed files with 153 additions and 87 deletions
+2 -1
View File
@@ -13,6 +13,7 @@ Lua-language sources stay under `lua/`; C/C++ and the vendored interpreter stay
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.
+6
View File
@@ -41,6 +41,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.
+8 -11
View File
@@ -4,20 +4,17 @@
-- The firmware loads /.lua/main.lua 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 main.lua's to decide.
-- itself sees are all main.lua's to decide, which is why an app composes the
-- classes for the features it handles:
--
-- Fields the firmware reads: home, the route sys.back() lands on once history is
-- empty, and data, the sys.getAppDataPath() template whose ? is the app id.
-- ---@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. Mounts the route; failing here leaves no app running.
---@param route string The app path sys.launch, sys.back or the boot recorded.
---@param arg? string The string passed to sys.launch or sys.replace.
function start(route, arg) end
---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.
function draw(deltaMs) end
---@class App
---@field home? string The route sys.back() lands on once history is empty.
---@field data? string The sys.getAppDataPath() template whose ? is the app id.
---@field start fun(route: string, arg?: string) Required. Mounts the route; 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.
+7 -10
View File
@@ -33,14 +33,11 @@ function buttons.wasPressed(button) end
---@return boolean
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.
+8 -18
View File
@@ -28,22 +28,12 @@ function touch.isTouched() end
---@return string? error
function touch.setCalibration(x0, y0, x1, y1) end
---Fired when the finger lands.
---@param x integer
---@param y integer
function on_touch_down(x, y) end
-- What an app implements to see raw touch, composed into its own class:
--
-- ---@class PaintApp : App, TouchHandlers
---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.
+9 -5
View File
@@ -72,13 +72,17 @@ void registerButtons(lua_State* state) {
} // 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?
+10 -6
View File
@@ -76,21 +76,25 @@ void registerTouch(lua_State* state) {
} // 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?
+16 -14
View File
@@ -150,17 +150,16 @@ bool Runtime::finishCall(const char* name, int argc) {
return false;
}
// @lua-global core/runtime
// @lua-app App core/runtime
// @lua-preamble -- The firmware loads /.lua/main.lua into every fresh state and
// calls these on the
// @lua-preamble -- table it returns. Where apps live, what surrounds them and
// which of these an app
// @lua-preamble -- itself sees are all main.lua's to decide.
// @lua-preamble -- itself sees are all main.lua's to decide, which is why an
// app composes the
// @lua-preamble -- classes for the features it handles:
// @lua-preamble --
// @lua-preamble -- Fields the firmware reads: home, the route sys.back() lands
// on once history is
// @lua-preamble -- empty, and data, the sys.getAppDataPath() template whose ?
// is the app id.
// @lua-preamble -- ---@class PaintApp : App, TouchHandlers
// @lua-preamble --
// @lua-preamble -- The firmware does not clear the frame before calling draw(),
// and commits changed
@@ -168,6 +167,9 @@ bool Runtime::finishCall(const char* name, int argc) {
// own refresh policy.
// @lua-preamble -- Timer callbacks are registered directly with
// timer.after/every.
// @lua-field home? string The route sys.back() lands on once history is empty.
// @lua-field data? string The sys.getAppDataPath() template whose ? is the app
// id.
// ---Required. Mounts the route; failing here leaves no app running.
// @param route string The app path sys.launch, sys.back or the boot recorded.
@@ -188,7 +190,7 @@ bool Runtime::callStart(const std::string& route, const std::string& arg) {
// 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"))
@@ -206,16 +208,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;
@@ -232,12 +234,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;
+6 -6
View File
@@ -200,11 +200,11 @@ int main() {
"route .. ':' .. arg end,\n"
" draw = function(delta) if delta < 0 then error('kaboom') end\n"
" 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'),\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("Reader", "book.epub"));
@@ -212,7 +212,7 @@ int main() {
hosted.callDraw(33);
hosted.callTouch(esp32lua::TouchPhase::Down, 5, 6);
hosted.callTouch(esp32lua::TouchPhase::Move, 5,
7); // main.lua defines no on_touch_move
7); // main.lua defines no onTouchMove
hosted.callTouch(esp32lua::TouchPhase::Up, 5, 8);
hosted.callButton("confirm", false);
run(hosted.state(),
+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")