diff --git a/AGENTS.md b/AGENTS.md index 8cc1a6b..7240e4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index b803894..69085f9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lua/api/core/runtime.lua b/lua/api/core/runtime.lua index c08231c..065b5a8 100644 --- a/lua/api/core/runtime.lua +++ b/lua/api/core/runtime.lua @@ -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. diff --git a/lua/api/features/buttons.lua b/lua/api/features/buttons.lua index 503fbef..8a5a691 100644 --- a/lua/api/features/buttons.lua +++ b/lua/api/features/buttons.lua @@ -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. diff --git a/lua/api/features/touch.lua b/lua/api/features/touch.lua index 3928903..f4b5bec 100644 --- a/lua/api/features/touch.lua +++ b/lua/api/features/touch.lua @@ -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. diff --git a/native/src/bindings/features/buttons.cpp b/native/src/bindings/features/buttons.cpp index 70646a4..efacbcd 100644 --- a/native/src/bindings/features/buttons.cpp +++ b/native/src/bindings/features/buttons.cpp @@ -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? diff --git a/native/src/bindings/features/touch.cpp b/native/src/bindings/features/touch.cpp index 313bc07..631b198 100644 --- a/native/src/bindings/features/touch.cpp +++ b/native/src/bindings/features/touch.cpp @@ -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? diff --git a/native/src/runtime/runtime.cpp b/native/src/runtime/runtime.cpp index e6fd892..3718a29 100644 --- a/native/src/runtime/runtime.cpp +++ b/native/src/runtime/runtime.cpp @@ -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; diff --git a/native/test/runtime_test.cpp b/native/test/runtime_test.cpp index f3e702b..20ae71c 100644 --- a/native/test/runtime_test.cpp +++ b/native/test/runtime_test.cpp @@ -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(), diff --git a/tools/gen_api.py b/tools/gen_api.py index 60a1096..af6f499 100644 --- a/tools/gen_api.py +++ b/tools/gen_api.py @@ -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-(?Pmodule|augment) (?P\w+) (?P\w+)$") -GLOBAL = re.compile(r"^// @lua-global(?: (?P\S+))?$") -FUNCTION = re.compile(r"^// @lua-fn (?P\w+)$") +APP = re.compile(r"^// @lua-app (?P\w+)(?: (?P\S+))?$") +FUNCTION = re.compile(r"^// @lua-fn (?P\w+\??)$") CONST = re.compile(r"^// @lua-const (?P\w+) (?P\S+) (?P\S+)(?: (?P.*))?$") +FIELD = re.compile(r"^// @lua-field (?P\w+\??) (?P\S+)(?: (?P.*))?$") FIX = re.compile(r"^// @lua-(?Ppreamble|postamble) ?(?P.*)$") TABLE = re.compile(r"^\s*const luaL_Reg (?P\w+)\[\] = \{$") ENTRY = re.compile(r'^\s*\{"(?P\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 ''}") diff --git a/tools/test_gen_api.py b/tools/test_gen_api.py index 72c56ab..a898f4b 100644 --- a/tools/test_gen_api.py +++ b/tools/test_gen_api.py @@ -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")