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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -7,13 +7,16 @@ table that registers them:
|
||||
// @lua-preamble ---@alias Feature "touch"|"lcd"
|
||||
// @lua-module sys SysLib creates the global
|
||||
// @lua-augment gui GuiLib extends a global another file created
|
||||
// @lua-global core/runtime app callbacks, written to an explicit path
|
||||
// @lua-app App a class of fields on the table an app returns
|
||||
// @lua-const MAX_READ_BYTES integer 65536 Largest portable read.
|
||||
// @lua-field home? string a plain field of a @lua-app class
|
||||
// @lua-postamble -- notes rendered after the module
|
||||
|
||||
Per function, `// --- text`, `// @param name type desc`, and `// @return type desc`. Functions come
|
||||
from the luaL_Reg entries below the directive, or from `// @lua-fn name` for the app callbacks the
|
||||
runtime calls rather than registers.
|
||||
from the luaL_Reg entries below the directive, or from `// @lua-fn name` for the callbacks the
|
||||
runtime calls on an app's table rather than registers. A `@lua-fn` name ending in `?` is one the
|
||||
app may leave out; the class it lands in is what an app composes, so a device without the feature
|
||||
contributes no fields rather than the runtime hiding some.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -25,9 +28,10 @@ SOURCES = [ROOT / "native/src/bindings", ROOT / "native/src/runtime"]
|
||||
OUTPUT = ROOT / "lua/api"
|
||||
|
||||
MODULE = re.compile(r"^// @lua-(?P<kind>module|augment) (?P<name>\w+) (?P<class_>\w+)$")
|
||||
GLOBAL = re.compile(r"^// @lua-global(?: (?P<path>\S+))?$")
|
||||
FUNCTION = re.compile(r"^// @lua-fn (?P<name>\w+)$")
|
||||
APP = re.compile(r"^// @lua-app (?P<class_>\w+)(?: (?P<path>\S+))?$")
|
||||
FUNCTION = re.compile(r"^// @lua-fn (?P<name>\w+\??)$")
|
||||
CONST = re.compile(r"^// @lua-const (?P<name>\w+) (?P<type>\S+) (?P<value>\S+)(?: (?P<desc>.*))?$")
|
||||
FIELD = re.compile(r"^// @lua-field (?P<name>\w+\??) (?P<type>\S+)(?: (?P<desc>.*))?$")
|
||||
FIX = re.compile(r"^// @lua-(?P<where>preamble|postamble) ?(?P<text>.*)$")
|
||||
TABLE = re.compile(r"^\s*const luaL_Reg (?P<var>\w+)\[\] = \{$")
|
||||
ENTRY = re.compile(r'^\s*\{"(?P<name>\w+)",\s*\w+\},?$')
|
||||
@@ -53,6 +57,7 @@ def new_module(kind, name, class_name, path=None):
|
||||
"class": class_name,
|
||||
"path": path,
|
||||
"consts": [],
|
||||
"fields": [],
|
||||
"preamble": [],
|
||||
"postamble": [],
|
||||
"functions": [],
|
||||
@@ -92,18 +97,19 @@ def parse(path):
|
||||
pending_module = new_module(module.group("kind"), module.group("name"), module.group("class_"))
|
||||
modules.append(pending_module)
|
||||
continue
|
||||
glob = GLOBAL.match(stripped)
|
||||
if glob:
|
||||
# Callbacks are globals an app defines, so they need no table and no class.
|
||||
pending_module = new_module("global", None, None, glob.group("path"))
|
||||
app = APP.match(stripped)
|
||||
if app:
|
||||
# Callbacks are fields of the table an app returns, so the block is a class
|
||||
# with no table of its own to register.
|
||||
pending_module = new_module("app", None, app.group("class_"), app.group("path"))
|
||||
modules.append(pending_module)
|
||||
current, in_table = pending_module, False
|
||||
doc = reset()
|
||||
continue
|
||||
function = FUNCTION.match(stripped)
|
||||
if function:
|
||||
if not current or current["kind"] != "global":
|
||||
raise SystemExit(f"{where}: @lua-fn outside a @lua-global block")
|
||||
if not current or current["kind"] != "app":
|
||||
raise SystemExit(f"{where}: @lua-fn outside a @lua-app block")
|
||||
if not doc["doc"]:
|
||||
raise SystemExit(f"{where}: {function.group('name')} has no description")
|
||||
current["functions"].append((function.group("name"), doc))
|
||||
@@ -120,6 +126,15 @@ def parse(path):
|
||||
)
|
||||
continuation = (target["consts"], len(target["consts"]) - 1, 3)
|
||||
continue
|
||||
field = FIELD.match(stripped)
|
||||
if field:
|
||||
if not target or target["kind"] != "app":
|
||||
raise SystemExit(f"{where}: @lua-field outside a @lua-app block")
|
||||
target["fields"].append(
|
||||
(field.group("name"), lua_type(field.group("type")), field.group("desc") or "")
|
||||
)
|
||||
continuation = (target["fields"], len(target["fields"]) - 1, 2)
|
||||
continue
|
||||
fix = FIX.match(stripped)
|
||||
if fix:
|
||||
if not target:
|
||||
@@ -135,8 +150,8 @@ def parse(path):
|
||||
raise SystemExit(f"{where}: {table.group('var')} has no @lua-module or @lua-augment")
|
||||
current, pending_module, in_table, doc = pending_module, None, True, reset()
|
||||
continue
|
||||
# A @lua-global block documents callbacks the runtime calls, so it has no table to sit in.
|
||||
if not in_table and not (current and current["kind"] == "global"):
|
||||
# A @lua-app block documents callbacks the runtime calls, so it has no table to sit in.
|
||||
if not in_table and not (current and current["kind"] == "app"):
|
||||
continue
|
||||
if stripped == "};":
|
||||
in_table = False
|
||||
@@ -169,13 +184,28 @@ def parse(path):
|
||||
doc = reset()
|
||||
|
||||
for module in modules:
|
||||
if module["kind"] == "global":
|
||||
if module["kind"] == "app":
|
||||
continue
|
||||
if not module["functions"]:
|
||||
raise SystemExit(f"{path.relative_to(ROOT)}: {module['name']} registers no annotated functions")
|
||||
return modules
|
||||
|
||||
|
||||
def signature(doc):
|
||||
"""The `fun(...)` type of one callback, for the class an app composes."""
|
||||
params = ", ".join(
|
||||
f"{param}{'?' if type_.endswith('?') else ''}: {type_.rstrip('?')}" for param, type_, _ in doc["params"]
|
||||
)
|
||||
returns = ", ".join(type_ for type_, _ in doc["returns"])
|
||||
return f"fun({params})" + (f": {returns}" if returns else "")
|
||||
|
||||
|
||||
def describe(doc):
|
||||
"""A field carries one line, so a wrapped description joins back into a sentence."""
|
||||
text = " ".join(doc["doc"])
|
||||
return f" {text}" if text else ""
|
||||
|
||||
|
||||
def render(source, modules):
|
||||
lines = ["---@meta", "", f"-- Generated from {source.relative_to(ROOT)}. Do not edit.", ""]
|
||||
for module in modules:
|
||||
@@ -184,7 +214,18 @@ def render(source, modules):
|
||||
lines.extend(module["preamble"])
|
||||
if module["preamble"]:
|
||||
lines.append("")
|
||||
if module["kind"] != "global":
|
||||
if module["kind"] == "app":
|
||||
lines.append(f"---@class {module['class']}")
|
||||
for field, type_, desc in module["fields"]:
|
||||
lines.append(f"---@field {field} {type_}{(' ' + desc) if desc else ''}")
|
||||
for function, doc in module["functions"]:
|
||||
lines.append(f"---@field {function} {signature(doc)}{describe(doc)}")
|
||||
lines.append("")
|
||||
if module["postamble"]:
|
||||
lines.extend(module["postamble"] + [""])
|
||||
continue
|
||||
|
||||
if module["kind"] != "app":
|
||||
lines.append(f"---@class {module['class']}")
|
||||
for const, type_, _, desc in module["consts"]:
|
||||
lines.append(f"---@field {const} {type_}{(' ' + desc) if desc else ''}")
|
||||
|
||||
+25
-1
@@ -3,7 +3,7 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from gen_api import ROOT, parse
|
||||
from gen_api import ROOT, parse, render
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=ROOT) as directory:
|
||||
@@ -30,4 +30,28 @@ doc = module["functions"][0][1]
|
||||
assert doc["doc"] == ["Reads one complete file."]
|
||||
assert doc["params"][0][2] == "Absolute file path."
|
||||
assert doc["returns"][0][1] == "File contents."
|
||||
|
||||
# Callbacks render as fields of a class an app composes, never as global functions.
|
||||
with tempfile.TemporaryDirectory(dir=ROOT) as directory:
|
||||
source = Path(directory) / "handlers.cpp"
|
||||
source.write_text(
|
||||
"""// @lua-app TouchHandlers
|
||||
// @lua-field home? string The route back lands on.
|
||||
// ---Fired when the finger lands.
|
||||
// @param x integer
|
||||
// @param y integer
|
||||
// @lua-fn onTouchDown?
|
||||
// ---Mounts the route.
|
||||
// @param route string
|
||||
// @param arg string|nil
|
||||
// @lua-fn start
|
||||
"""
|
||||
)
|
||||
output = render(source, parse(source))
|
||||
|
||||
assert "---@class TouchHandlers" in output
|
||||
assert "---@field home? string The route back lands on." in output
|
||||
assert "---@field onTouchDown? fun(x: integer, y: integer) Fired when the finger lands." in output
|
||||
assert "---@field start fun(route: string, arg?: string) Mounts the route." in output
|
||||
assert "function " not in output
|
||||
print("ok")
|
||||
|
||||
Reference in New Issue
Block a user