webvas 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +97 -0
- data/js/bridge.js +72 -0
- data/js/loader.js +86 -0
- data/js/worker.js +47 -0
- data/lib/webvas/backend.rb +56 -0
- data/lib/webvas/bridge.rb +41 -0
- data/lib/webvas/input.rb +53 -0
- data/lib/webvas/key_map.rb +30 -0
- data/lib/webvas/runner.rb +13 -0
- data/lib/webvas/version.rb +5 -0
- data/lib/webvas.rb +39 -0
- data/sig/rbgl.rbs +47 -0
- data/sig/webvas.rbs +48 -0
- metadata +162 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5741995c39bcdce271bdf34cf96cc377d48cfb43abc9705d8beb6f2b49345034
|
|
4
|
+
data.tar.gz: 77fee6bec1cafd49ff8fb62b65ca00afeff04e618986b73977b375b13d652ad7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7b98cc6279a621f860e0bde96266c11c54913e5331d0a80f4a880ae17caf3b277bd3699dbf5c34913a4376efc792b05b1793ec152933438d4d2a6819789a21cd
|
|
7
|
+
data.tar.gz: cf95c627be83f6525fd9a5205ffd253367cb06fc6aed829e9d1f056b1bad192c6a41f983e145f71c18b7d494faf6cc38adf10df0b95752eeb53192e2be6485f5
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# Webvas
|
|
4
|
+
|
|
5
|
+
**Run RBGL sketches in a browser with Ruby.**
|
|
6
|
+
|
|
7
|
+
Webvas connects RBGL's software renderer to an `OffscreenCanvas` and drives one frame at a time with `requestAnimationFrame`.
|
|
8
|
+
|
|
9
|
+
[Live example](https://rbgfx.github.io/webvas/) · [Ruby API](#ruby-api) · [Browser requirements](#browser-requirements)
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- RBGL browser backend for RGBA frames and pointer, keyboard, and wheel events
|
|
16
|
+
- Frame scheduling through `RBGL::GUI::Window#step`
|
|
17
|
+
- Standalone loader for `<script type="text/ruby" data-webvas>`
|
|
18
|
+
- Runtime published with the example site; no local WebAssembly build is needed to use it
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
Add Webvas to the Ruby bundle used to prepare your WebAssembly runtime:
|
|
23
|
+
|
|
24
|
+
~~~ruby
|
|
25
|
+
gem "webvas"
|
|
26
|
+
~~~
|
|
27
|
+
|
|
28
|
+
The browser runtime must also include `rbgl` and its dependencies. When using the runtime distributed with the Webvas example, no local build is required.
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
Add a canvas, a Ruby script, and the loader to an HTML page:
|
|
33
|
+
|
|
34
|
+
~~~html
|
|
35
|
+
<canvas id="screen" width="320" height="240"></canvas>
|
|
36
|
+
<script type="text/ruby" data-webvas>
|
|
37
|
+
require "rbgl"
|
|
38
|
+
require "webvas"
|
|
39
|
+
|
|
40
|
+
window = RBGL::GUI::Window.new(
|
|
41
|
+
width: 320,
|
|
42
|
+
height: 240,
|
|
43
|
+
backend: Webvas::Backend.new(width: 320, height: 240)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
Webvas.run(window) do |context, _delta_time|
|
|
47
|
+
context.clear
|
|
48
|
+
# Bind an RBGL pipeline and draw here.
|
|
49
|
+
end
|
|
50
|
+
</script>
|
|
51
|
+
<script src="https://rbgfx.github.io/webvas/loader.js"></script>
|
|
52
|
+
~~~
|
|
53
|
+
|
|
54
|
+
The loader reads `assets/webvas.wasm` beside itself by default. Set `data-runtime` on the loader script to use another HTTPS or same-origin runtime URL. Set `data-canvas` on the Ruby script and use the same selector in `Webvas::Backend` when the canvas is not `#screen`.
|
|
55
|
+
|
|
56
|
+
## Ruby API
|
|
57
|
+
|
|
58
|
+
Create an `RBGL::GUI::Window` with a `Webvas::Backend`, then pass it to `Webvas.run`. The callback receives the RBGL context and elapsed seconds. `Webvas.run` reuses one callback for each animation frame and closes the window when it stops or raises an exception.
|
|
59
|
+
|
|
60
|
+
`Webvas::Backend` accepts `width`, `height`, `canvas`, `title`, and `pixelated`. The canvas size sets the rendering resolution; CSS can scale its display size. With `pixelated: true`, scaled output uses nearest-neighbor display.
|
|
61
|
+
|
|
62
|
+
Pointer coordinates are mapped from the canvas display bounds to rendering pixels. Pointer buttons, keyboard keys, modifier keys, and wheel deltas are converted to RBGL events. Focus the canvas to send keyboard events.
|
|
63
|
+
|
|
64
|
+
## Browser requirements
|
|
65
|
+
|
|
66
|
+
- WebAssembly, module workers, and `OffscreenCanvas`
|
|
67
|
+
- A secure context when loading the page over the network
|
|
68
|
+
- A Ruby runtime built with Webvas, RBGL, and RBGL's dependencies
|
|
69
|
+
|
|
70
|
+
Ruby runs in a worker without access to the page DOM. The `js` gem exposes APIs available to that worker, so this worker should not be treated as a security sandbox for hostile code. The page's Content Security Policy must allow the worker, runtime URL, and WebAssembly compilation.
|
|
71
|
+
|
|
72
|
+
Blocking frame loops and `sleep` cannot drive animation in the browser. Use `Webvas.run` to advance the window one frame at a time. WebAssembly threads and compiling native extensions in the browser are not supported.
|
|
73
|
+
|
|
74
|
+
Ruby source is limited to 32 KiB. Frame pixels use base64 between Ruby and JavaScript; a 320×240 RGBA frame is 307,200 bytes before encoding and 409,600 bytes after encoding.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
The repository expects adjacent checkouts of `larb`, `rbgl`, and `tessel`.
|
|
79
|
+
|
|
80
|
+
~~~sh
|
|
81
|
+
bundle install
|
|
82
|
+
BUNDLE_GEMFILE=runtime/Gemfile bundle install
|
|
83
|
+
BUNDLE_GEMFILE=runtime/Gemfile bundle exec rbwasm build \
|
|
84
|
+
--ruby-version 4.0 --target wasm32-unknown-wasip1 \
|
|
85
|
+
--build-profile full --patch "$(pwd)/patches/psych-wasi.patch" -o build/webvas.wasm
|
|
86
|
+
ruby script/build_site
|
|
87
|
+
bundle exec rake verify
|
|
88
|
+
npm ci
|
|
89
|
+
npx playwright install chromium
|
|
90
|
+
npm run test:browser
|
|
91
|
+
~~~
|
|
92
|
+
|
|
93
|
+
The Pages workflow builds the WebAssembly runtime, checks the browser loader in Chromium, and deploys the static example.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
data/js/bridge.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
let nextId = 1;
|
|
3
|
+
const canvases = new Map();
|
|
4
|
+
const pendingEvents = [];
|
|
5
|
+
const eventLimit = 2048;
|
|
6
|
+
|
|
7
|
+
function queue(handle, event) {
|
|
8
|
+
if (handle.events.length === eventLimit) handle.events.shift();
|
|
9
|
+
handle.events.push(event);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
globalThis.WebvasBridge = {
|
|
13
|
+
attach(selector, width, height, pixelated) {
|
|
14
|
+
const canvas = globalThis.WebvasCanvases[selector];
|
|
15
|
+
if (!(canvas instanceof OffscreenCanvas)) throw new Error("Canvas not found: " + selector);
|
|
16
|
+
canvas.width = width;
|
|
17
|
+
canvas.height = height;
|
|
18
|
+
const context = canvas.getContext("2d", { alpha: false });
|
|
19
|
+
if (!context) throw new Error("Canvas 2D is unavailable");
|
|
20
|
+
const handle = { id: nextId++, canvas, context, events: pendingEvents.splice(0) };
|
|
21
|
+
globalThis.postMessage({ type: "webvas:pixelated", selector, enabled: pixelated });
|
|
22
|
+
canvases.set(handle.id, handle);
|
|
23
|
+
return handle.id;
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
present(id, encoded, width, height) {
|
|
27
|
+
const handle = canvases.get(id);
|
|
28
|
+
if (!handle) throw new Error("Canvas backend is closed");
|
|
29
|
+
const { canvas, context } = handle;
|
|
30
|
+
if (canvas.width !== width || canvas.height !== height) {
|
|
31
|
+
canvas.width = width;
|
|
32
|
+
canvas.height = height;
|
|
33
|
+
}
|
|
34
|
+
const binary = atob(encoded);
|
|
35
|
+
if (binary.length !== width * height * 4) throw new Error("RGBA frame size mismatch");
|
|
36
|
+
const pixels = new Uint8ClampedArray(binary.length);
|
|
37
|
+
for (let index = 0; index < binary.length; index += 1) pixels[index] = binary.charCodeAt(index);
|
|
38
|
+
context.putImageData(new ImageData(pixels, width, height), 0, 0);
|
|
39
|
+
return true;
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
events(id) {
|
|
43
|
+
const handle = canvases.get(id);
|
|
44
|
+
return JSON.stringify(handle ? handle.events.splice(0) : []);
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
pushEvent(event) {
|
|
48
|
+
if (!canvases.size) {
|
|
49
|
+
if (pendingEvents.length === eventLimit) pendingEvents.shift();
|
|
50
|
+
pendingEvents.push(event);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const handle of canvases.values()) queue(handle, event);
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
resize(id, width, height) {
|
|
57
|
+
const handle = canvases.get(id);
|
|
58
|
+
if (!handle) return false;
|
|
59
|
+
handle.canvas.width = width;
|
|
60
|
+
handle.canvas.height = height;
|
|
61
|
+
return true;
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
close(id) {
|
|
65
|
+
canvases.delete(id);
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
showError(message, backtrace) {
|
|
69
|
+
globalThis.postMessage({ type: "webvas:error", message, backtrace });
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
})();
|
data/js/loader.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const loader = document.currentScript;
|
|
3
|
+
if (!loader) return;
|
|
4
|
+
|
|
5
|
+
const base = new URL(".", loader.src);
|
|
6
|
+
const runtimeUrl = loader.dataset.runtime || new URL("./assets/webvas.wasm", base).href;
|
|
7
|
+
const workerUrl = new URL("./worker.js", base).href;
|
|
8
|
+
const bridgeUrl = new URL("./bridge.js", base).href;
|
|
9
|
+
|
|
10
|
+
async function run(sourceElement) {
|
|
11
|
+
const selector = sourceElement.dataset.canvas || "#screen";
|
|
12
|
+
const canvas = document.querySelector(selector);
|
|
13
|
+
if (!(canvas instanceof HTMLCanvasElement)) throw new Error(`Canvas not found: ${selector}`);
|
|
14
|
+
if (new TextEncoder().encode(sourceElement.textContent).length > 32768) throw new Error("Ruby source exceeds 32 KB.");
|
|
15
|
+
|
|
16
|
+
const status = document.createElement("output");
|
|
17
|
+
status.setAttribute("role", "status");
|
|
18
|
+
status.setAttribute("aria-live", "polite");
|
|
19
|
+
canvas.insertAdjacentElement("afterend", status);
|
|
20
|
+
const report = message => { status.textContent = message; };
|
|
21
|
+
report("Loading Ruby runtime…");
|
|
22
|
+
if (canvas.tabIndex < 0) canvas.tabIndex = 0;
|
|
23
|
+
|
|
24
|
+
const response = await fetch(workerUrl, { credentials: "omit" });
|
|
25
|
+
if (!response.ok) throw new Error(`Worker download failed: ${response.status}`);
|
|
26
|
+
const workerBlobUrl = URL.createObjectURL(new Blob([await response.text()], { type: "text/javascript" }));
|
|
27
|
+
const worker = new Worker(workerBlobUrl, { type: "module", name: "webvas" });
|
|
28
|
+
worker.addEventListener("message", event => {
|
|
29
|
+
const message = event.data || {};
|
|
30
|
+
if (message.type === "webvas:loading") report(message.message || "Loading Ruby runtime…");
|
|
31
|
+
else if (message.type === "webvas:ready") {
|
|
32
|
+
URL.revokeObjectURL(workerBlobUrl);
|
|
33
|
+
worker.postMessage({ type: "webvas:run", source: sourceElement.textContent });
|
|
34
|
+
} else if (message.type === "webvas:started") report("Ruby sketch is running");
|
|
35
|
+
else if (message.type === "webvas:error") report([message.message, message.backtrace].filter(Boolean).join("\n"));
|
|
36
|
+
else if (message.type === "webvas:pixelated" && message.selector === selector) {
|
|
37
|
+
canvas.style.imageRendering = message.enabled ? "pixelated" : "auto";
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
worker.addEventListener("error", event => {
|
|
41
|
+
URL.revokeObjectURL(workerBlobUrl);
|
|
42
|
+
report(event.message || "Webvas worker failed");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const forward = event => {
|
|
46
|
+
if (event.type === "wheel") event.preventDefault();
|
|
47
|
+
if (event.type === "keydown" && event.code === "Space") event.preventDefault();
|
|
48
|
+
if (event.type === "pointerdown") {
|
|
49
|
+
canvas.focus({ preventScroll: true });
|
|
50
|
+
canvas.setPointerCapture(event.pointerId);
|
|
51
|
+
}
|
|
52
|
+
const rect = canvas.getBoundingClientRect();
|
|
53
|
+
worker.postMessage({
|
|
54
|
+
type: "webvas:input",
|
|
55
|
+
event: {
|
|
56
|
+
type: event.type === "pointercancel" ? "pointerup" : event.type,
|
|
57
|
+
clientX: event.clientX || 0, clientY: event.clientY || 0,
|
|
58
|
+
left: rect.left, top: rect.top, rectWidth: rect.width, rectHeight: rect.height,
|
|
59
|
+
button: event.button, deltaX: event.deltaX, deltaY: event.deltaY,
|
|
60
|
+
code: event.code, key: event.key, shiftKey: event.shiftKey,
|
|
61
|
+
ctrlKey: event.ctrlKey, altKey: event.altKey, metaKey: event.metaKey
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
for (const type of ["pointerdown", "pointerup", "pointermove", "pointercancel", "keydown", "keyup", "wheel"]) {
|
|
66
|
+
canvas.addEventListener(type, forward, type === "wheel" ? { passive: false } : undefined);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const offscreen = canvas.transferControlToOffscreen();
|
|
70
|
+
worker.postMessage({
|
|
71
|
+
type: "webvas:init", canvas: offscreen, selector,
|
|
72
|
+
bridgeUrl, wasmUrl: new URL(runtimeUrl, document.baseURI).href
|
|
73
|
+
}, [offscreen]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const start = () => document.querySelectorAll('script[type="text/ruby"][data-webvas]').forEach(element => {
|
|
77
|
+
run(element).catch(error => {
|
|
78
|
+
const status = document.createElement("output");
|
|
79
|
+
status.setAttribute("role", "alert");
|
|
80
|
+
status.textContent = error.message || String(error);
|
|
81
|
+
element.insertAdjacentElement("afterend", status);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start, { once: true });
|
|
85
|
+
else start();
|
|
86
|
+
})();
|
data/js/worker.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
let vm;
|
|
2
|
+
let busy = false;
|
|
3
|
+
const pendingEvents = [];
|
|
4
|
+
|
|
5
|
+
globalThis.window = {
|
|
6
|
+
requestAnimationFrame(callback) {
|
|
7
|
+
return setTimeout(() => callback(performance.now()), 1000 / 60);
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
globalThis.requestAnimationFrame ||= callback => globalThis.window.requestAnimationFrame(callback);
|
|
11
|
+
|
|
12
|
+
function send(type, payload = {}) {
|
|
13
|
+
globalThis.postMessage({ type: "webvas:" + type, ...payload });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
globalThis.addEventListener("message", async event => {
|
|
17
|
+
const message = event.data;
|
|
18
|
+
try {
|
|
19
|
+
if (message?.type === "webvas:init") {
|
|
20
|
+
await import(message.bridgeUrl);
|
|
21
|
+
globalThis.WebvasCanvases = { [message.selector]: message.canvas };
|
|
22
|
+
pendingEvents.splice(0).forEach(input => globalThis.WebvasBridge.pushEvent(input));
|
|
23
|
+
send("loading", { message: "Downloading the Ruby drawing machine…" });
|
|
24
|
+
const { DefaultRubyVM } = await import("https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.10.1/dist/browser/+esm");
|
|
25
|
+
const response = await fetch(message.wasmUrl, { credentials: "omit" });
|
|
26
|
+
if (!response.ok) throw new Error("Ruby runtime download failed: " + response.status);
|
|
27
|
+
const module = await WebAssembly.compile(await response.arrayBuffer());
|
|
28
|
+
send("loading", { message: "Starting Ruby and graphics libraries…" });
|
|
29
|
+
({ vm } = await DefaultRubyVM(module));
|
|
30
|
+
send("ready");
|
|
31
|
+
} else if (message?.type === "webvas:input") {
|
|
32
|
+
if (globalThis.WebvasBridge) globalThis.WebvasBridge.pushEvent(message.event);
|
|
33
|
+
else if (pendingEvents.length < 2048) pendingEvents.push(message.event);
|
|
34
|
+
} else if (message?.type === "webvas:run" && vm && !busy && typeof message.source === "string") {
|
|
35
|
+
if (new TextEncoder().encode(message.source).length > 32768) throw new Error("Source exceeds 32 KB.");
|
|
36
|
+
busy = true;
|
|
37
|
+
vm.eval(message.source);
|
|
38
|
+
send("started");
|
|
39
|
+
busy = false;
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
busy = false;
|
|
43
|
+
send("error", { message: error.message || String(error), backtrace: error.stack || "" });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
globalThis.addEventListener("error", event => send("error", { message: event.message || "Worker error" }));
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webvas
|
|
4
|
+
class Backend < RBGL::GUI::Backend
|
|
5
|
+
def initialize(width:, height:, canvas: "#screen", title: "Webvas", pixelated: false, bridge: Bridge.new)
|
|
6
|
+
super(width, height, title)
|
|
7
|
+
@bridge = bridge
|
|
8
|
+
@handle = @bridge.attach(canvas, width, height, pixelated:)
|
|
9
|
+
@pending_pixels = nil
|
|
10
|
+
@closed = false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def present(framebuffer)
|
|
14
|
+
bytes, width, height = @pending_pixels || [framebuffer.to_rgba_bytes, framebuffer.width, framebuffer.height]
|
|
15
|
+
@pending_pixels = nil
|
|
16
|
+
@bridge.present(@handle, bytes, width, height)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def set_pixels(buffer, width, height)
|
|
20
|
+
width = Integer(width)
|
|
21
|
+
height = Integer(height)
|
|
22
|
+
bytes = validate_rgba_buffer(buffer, width, height)
|
|
23
|
+
resize(width, height) if [width, height] != [@width, @height]
|
|
24
|
+
@pending_pixels = [bytes, width, height]
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resize(width, height)
|
|
29
|
+
width = Integer(width)
|
|
30
|
+
height = Integer(height)
|
|
31
|
+
raise ArgumentError, "canvas size must be positive" unless width.positive? && height.positive?
|
|
32
|
+
|
|
33
|
+
super
|
|
34
|
+
@bridge.resize(@handle, width, height)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def poll_events
|
|
38
|
+
Input.translate(@bridge.events(@handle), width: @width, height: @height)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def poll_events_raw
|
|
42
|
+
poll_events.map(&:to_h)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def should_close?
|
|
46
|
+
@closed
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def close
|
|
50
|
+
return if @closed
|
|
51
|
+
|
|
52
|
+
@closed = true
|
|
53
|
+
@bridge.close(@handle)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Webvas
|
|
7
|
+
class Bridge
|
|
8
|
+
def initialize(api: nil)
|
|
9
|
+
require "js" unless api
|
|
10
|
+
@api = api || JS.global[:WebvasBridge]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def attach(selector, width, height, pixelated:)
|
|
14
|
+
@api.attach(selector, width, height, pixelated)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def present(handle, bytes, width, height)
|
|
18
|
+
@api.present(handle, Base64.strict_encode64(bytes), width, height)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def events(handle)
|
|
22
|
+
JSON.parse(@api.events(handle).to_s)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def resize(handle, width, height)
|
|
26
|
+
@api.resize(handle, width, height)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def close(handle)
|
|
30
|
+
@api.close(handle)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def request_animation_frame(callback)
|
|
34
|
+
JS.global[:window].requestAnimationFrame(callback)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def show_error(message, backtrace)
|
|
38
|
+
@api.showError(String(message), Array(backtrace).join("\n"))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/webvas/input.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webvas
|
|
4
|
+
module Input
|
|
5
|
+
MODIFIERS = { "shiftKey" => :shift, "ctrlKey" => :control, "altKey" => :alt, "metaKey" => :meta }.freeze
|
|
6
|
+
BUTTONS = { 0 => 1, 1 => 2, 2 => 3 }.freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def translate(events, width:, height:)
|
|
11
|
+
events.filter_map { |event| convert(event, width:, height:) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def convert(event, width:, height:)
|
|
15
|
+
case type = event.fetch("type")
|
|
16
|
+
when "pointerdown", "pointerup", "pointermove"
|
|
17
|
+
x, y = coordinates(event, width:, height:)
|
|
18
|
+
name = { "pointerdown" => :mouse_press, "pointerup" => :mouse_release, "pointermove" => :mouse_move }.fetch(type)
|
|
19
|
+
data = { x:, y:, modifiers: modifiers(event) }
|
|
20
|
+
data[:button] = BUTTONS.fetch(event.fetch("button", 0), event.fetch("button", 0)) unless type == "pointermove"
|
|
21
|
+
RBGL::GUI::Event.new(name, **data)
|
|
22
|
+
when "keydown", "keyup"
|
|
23
|
+
name = type == "keydown" ? :key_press : :key_release
|
|
24
|
+
key = KeyMap.call(event.fetch("code", ""), event["key"])
|
|
25
|
+
RBGL::GUI::Event.new(name, key:, keycode: event["code"], char: printable(event["key"]), modifiers: modifiers(event))
|
|
26
|
+
when "wheel"
|
|
27
|
+
RBGL::GUI::Event.new(:scroll, dx: event.fetch("deltaX", 0), dy: event.fetch("deltaY", 0), modifiers: modifiers(event))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def coordinates(event, width:, height:)
|
|
32
|
+
width = Integer(width)
|
|
33
|
+
height = Integer(height)
|
|
34
|
+
raise ArgumentError, "canvas size must be positive" unless width.positive? && height.positive?
|
|
35
|
+
|
|
36
|
+
rect_width = Float(event.fetch("rectWidth"))
|
|
37
|
+
rect_height = Float(event.fetch("rectHeight"))
|
|
38
|
+
return [0, 0] unless rect_width.positive? && rect_height.positive?
|
|
39
|
+
|
|
40
|
+
x = ((Float(event.fetch("clientX")) - Float(event.fetch("left"))) * width / rect_width).floor
|
|
41
|
+
y = ((Float(event.fetch("clientY")) - Float(event.fetch("top"))) * height / rect_height).floor
|
|
42
|
+
[x.clamp(0, width - 1), y.clamp(0, height - 1)]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def modifiers(event)
|
|
46
|
+
MODIFIERS.filter_map { |key, value| value if event[key] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def printable(key)
|
|
50
|
+
key if key.is_a?(String) && key.length == 1
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webvas
|
|
4
|
+
module KeyMap
|
|
5
|
+
DIGITS = %i[zero one two three four five six seven eight nine].freeze
|
|
6
|
+
SPECIAL = {
|
|
7
|
+
"ArrowLeft" => :left, "ArrowRight" => :right, "ArrowUp" => :up, "ArrowDown" => :down,
|
|
8
|
+
"Backspace" => :backspace, "Delete" => :delete, "Enter" => :enter, "Escape" => :escape,
|
|
9
|
+
"Home" => :home, "End" => :end, "PageUp" => :page_up, "PageDown" => :page_down,
|
|
10
|
+
"Tab" => :tab, "Space" => :space, "ShiftLeft" => :left_shift, "ShiftRight" => :right_shift,
|
|
11
|
+
"ControlLeft" => :left_control, "ControlRight" => :right_control,
|
|
12
|
+
"AltLeft" => :left_alt, "AltRight" => :right_alt, "MetaLeft" => :left_meta, "MetaRight" => :right_meta,
|
|
13
|
+
"Minus" => :minus, "Equal" => :equal, "BracketLeft" => :left_bracket,
|
|
14
|
+
"BracketRight" => :right_bracket, "Backslash" => :backslash, "Semicolon" => :semicolon,
|
|
15
|
+
"Quote" => :quote, "Comma" => :comma, "Period" => :period, "Slash" => :slash, "Backquote" => :grave
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def call(code, key = nil)
|
|
21
|
+
code = String(code)
|
|
22
|
+
return SPECIAL.fetch(code) if SPECIAL.key?(code)
|
|
23
|
+
return key.downcase.to_sym if code.start_with?("Key") && key.to_s.length == 1
|
|
24
|
+
return DIGITS.fetch(code.delete_prefix("Digit").to_i) if code.match?(/\ADigit[0-9]\z/)
|
|
25
|
+
return code.downcase.to_sym if code.match?(/\AF\d{1,2}\z/)
|
|
26
|
+
|
|
27
|
+
key.to_s.length == 1 ? key.downcase.to_sym : code.downcase.to_sym
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/webvas.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "webvas/version"
|
|
4
|
+
require "rbgl"
|
|
5
|
+
require_relative "webvas/bridge"
|
|
6
|
+
require_relative "webvas/backend"
|
|
7
|
+
require_relative "webvas/input"
|
|
8
|
+
require_relative "webvas/key_map"
|
|
9
|
+
require_relative "webvas/runner"
|
|
10
|
+
|
|
11
|
+
module Webvas
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
def self.run(window, scheduler: nil, on_error: nil, &frame_callback)
|
|
15
|
+
raise ArgumentError, "a frame callback is required" unless frame_callback
|
|
16
|
+
|
|
17
|
+
scheduler ||= Scheduler.new
|
|
18
|
+
callback = nil
|
|
19
|
+
callback = proc do |timestamp|
|
|
20
|
+
begin
|
|
21
|
+
if !window.should_close? && window.step(Float(timestamp) / 1000, &frame_callback) && !window.should_close?
|
|
22
|
+
scheduler.request_animation_frame(callback)
|
|
23
|
+
else
|
|
24
|
+
window.close
|
|
25
|
+
end
|
|
26
|
+
rescue StandardError => error
|
|
27
|
+
window.close
|
|
28
|
+
(on_error || method(:report_error)).call(error)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
scheduler.request_animation_frame(callback)
|
|
32
|
+
callback
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.report_error(error)
|
|
36
|
+
Bridge.new.show_error(error.message, error.backtrace || [])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
end
|
data/sig/rbgl.rbs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module RBGL
|
|
2
|
+
module Engine
|
|
3
|
+
class Framebuffer
|
|
4
|
+
attr_reader width: Integer
|
|
5
|
+
attr_reader height: Integer
|
|
6
|
+
def to_rgba_bytes: () -> String
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Context
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module GUI
|
|
14
|
+
class Event
|
|
15
|
+
attr_reader type: Symbol
|
|
16
|
+
def initialize: (Symbol, **untyped) -> void
|
|
17
|
+
def []: (Symbol | String key) -> untyped
|
|
18
|
+
def to_h: () -> Hash[Symbol, untyped]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Backend
|
|
22
|
+
attr_reader width: Integer
|
|
23
|
+
attr_reader height: Integer
|
|
24
|
+
attr_reader title: String
|
|
25
|
+
def initialize: (Integer width, Integer height, ?String title, **untyped) -> void
|
|
26
|
+
def present: (RBGL::Engine::Framebuffer framebuffer) -> untyped
|
|
27
|
+
def poll_events: () -> Array[RBGL::GUI::Event]
|
|
28
|
+
def poll_events_raw: () -> Array[Hash[Symbol, untyped]]
|
|
29
|
+
def resize: (Integer width, Integer height) -> void
|
|
30
|
+
def set_pixels: (String buffer, Integer width, Integer height) -> untyped
|
|
31
|
+
def should_close?: () -> bool
|
|
32
|
+
def close: () -> void
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class Window
|
|
36
|
+
attr_reader context: RBGL::Engine::Context
|
|
37
|
+
attr_reader backend: RBGL::GUI::Backend
|
|
38
|
+
def initialize: (width: Integer, height: Integer, ?title: String, ?backend: Symbol | RBGL::GUI::Backend, **untyped) -> void
|
|
39
|
+
def on: (Symbol event_type) { (RBGL::GUI::Event) -> void } -> void
|
|
40
|
+
def step: (?Float now) { (RBGL::Engine::Context, Float) -> void } -> bool
|
|
41
|
+
def stop: () -> void
|
|
42
|
+
def set_pixels: (String buffer) -> untyped
|
|
43
|
+
def should_close?: () -> bool
|
|
44
|
+
def close: () -> void
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/sig/webvas.rbs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Webvas
|
|
2
|
+
VERSION: String
|
|
3
|
+
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class Bridge
|
|
8
|
+
def initialize: (?api: untyped) -> void
|
|
9
|
+
def attach: (String selector, Integer width, Integer height, pixelated: bool) -> untyped
|
|
10
|
+
def present: (untyped handle, String bytes, Integer width, Integer height) -> untyped
|
|
11
|
+
def events: (untyped handle) -> Array[Hash[String, untyped]]
|
|
12
|
+
def resize: (untyped handle, Integer width, Integer height) -> untyped
|
|
13
|
+
def close: (untyped handle) -> untyped
|
|
14
|
+
def request_animation_frame: (Proc callback) -> untyped
|
|
15
|
+
def show_error: (String message, Array[String] backtrace) -> untyped
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Backend < RBGL::GUI::Backend
|
|
19
|
+
def initialize: (width: Integer, height: Integer, ?canvas: String, ?title: String, ?pixelated: bool, ?bridge: Bridge) -> void
|
|
20
|
+
def present: (RBGL::Engine::Framebuffer framebuffer) -> untyped
|
|
21
|
+
def set_pixels: (String buffer, Integer width, Integer height) -> bool
|
|
22
|
+
def resize: (Integer width, Integer height) -> untyped
|
|
23
|
+
def poll_events: () -> Array[RBGL::GUI::Event]
|
|
24
|
+
def poll_events_raw: () -> Array[Hash[Symbol, untyped]]
|
|
25
|
+
def should_close?: () -> bool
|
|
26
|
+
def close: () -> void
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module Input
|
|
30
|
+
def self.translate: (Array[Hash[String, untyped]] events, width: Integer, height: Integer) -> Array[RBGL::GUI::Event]
|
|
31
|
+
def self.convert: (Hash[String, untyped] event, width: Integer, height: Integer) -> RBGL::GUI::Event?
|
|
32
|
+
def self.coordinates: (Hash[String, untyped] event, width: Integer, height: Integer) -> [Integer, Integer]
|
|
33
|
+
def self.modifiers: (Hash[String, untyped] event) -> Array[Symbol]
|
|
34
|
+
def self.printable: (untyped key) -> String?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
module KeyMap
|
|
38
|
+
def self.call: (String code, ?untyped key) -> Symbol
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class Scheduler
|
|
42
|
+
def initialize: (?bridge: Bridge) -> void
|
|
43
|
+
def request_animation_frame: (Proc callback) -> untyped
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.run: (RBGL::GUI::Window window, ?scheduler: Scheduler, ?on_error: Proc) { (RBGL::Engine::Context, Float) -> void } -> Proc
|
|
47
|
+
def self.report_error: (StandardError error) -> untyped
|
|
48
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: webvas
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rbgl
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 1.0.0
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '2'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 1.0.0
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '2'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: base64
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '0.2'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '0.2'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: rake
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - "~>"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '13.0'
|
|
53
|
+
type: :development
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '13.0'
|
|
60
|
+
- !ruby/object:Gem::Dependency
|
|
61
|
+
name: rbs
|
|
62
|
+
requirement: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - "~>"
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '3.0'
|
|
67
|
+
type: :development
|
|
68
|
+
prerelease: false
|
|
69
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - "~>"
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '3.0'
|
|
74
|
+
- !ruby/object:Gem::Dependency
|
|
75
|
+
name: rubocop
|
|
76
|
+
requirement: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - "~>"
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '1.0'
|
|
81
|
+
type: :development
|
|
82
|
+
prerelease: false
|
|
83
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - "~>"
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '1.0'
|
|
88
|
+
- !ruby/object:Gem::Dependency
|
|
89
|
+
name: simplecov
|
|
90
|
+
requirement: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - "~>"
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '0.22'
|
|
95
|
+
type: :development
|
|
96
|
+
prerelease: false
|
|
97
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - "~>"
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '0.22'
|
|
102
|
+
- !ruby/object:Gem::Dependency
|
|
103
|
+
name: test-unit
|
|
104
|
+
requirement: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - "~>"
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '3.6'
|
|
109
|
+
type: :development
|
|
110
|
+
prerelease: false
|
|
111
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - "~>"
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '3.6'
|
|
116
|
+
description: An RBGL canvas backend and browser loader powered by ruby.wasm.
|
|
117
|
+
email:
|
|
118
|
+
- t.yudai92@gmail.com
|
|
119
|
+
executables: []
|
|
120
|
+
extensions: []
|
|
121
|
+
extra_rdoc_files: []
|
|
122
|
+
files:
|
|
123
|
+
- CHANGELOG.md
|
|
124
|
+
- LICENSE.txt
|
|
125
|
+
- README.md
|
|
126
|
+
- js/bridge.js
|
|
127
|
+
- js/loader.js
|
|
128
|
+
- js/worker.js
|
|
129
|
+
- lib/webvas.rb
|
|
130
|
+
- lib/webvas/backend.rb
|
|
131
|
+
- lib/webvas/bridge.rb
|
|
132
|
+
- lib/webvas/input.rb
|
|
133
|
+
- lib/webvas/key_map.rb
|
|
134
|
+
- lib/webvas/runner.rb
|
|
135
|
+
- lib/webvas/version.rb
|
|
136
|
+
- sig/rbgl.rbs
|
|
137
|
+
- sig/webvas.rbs
|
|
138
|
+
homepage: https://github.com/rbgfx/webvas
|
|
139
|
+
licenses:
|
|
140
|
+
- MIT
|
|
141
|
+
metadata:
|
|
142
|
+
homepage_uri: https://github.com/rbgfx/webvas
|
|
143
|
+
source_code_uri: https://github.com/rbgfx/webvas/tree/main
|
|
144
|
+
rubygems_mfa_required: 'true'
|
|
145
|
+
rdoc_options: []
|
|
146
|
+
require_paths:
|
|
147
|
+
- lib
|
|
148
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
149
|
+
requirements:
|
|
150
|
+
- - ">="
|
|
151
|
+
- !ruby/object:Gem::Version
|
|
152
|
+
version: 3.1.0
|
|
153
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
154
|
+
requirements:
|
|
155
|
+
- - ">="
|
|
156
|
+
- !ruby/object:Gem::Version
|
|
157
|
+
version: '0'
|
|
158
|
+
requirements: []
|
|
159
|
+
rubygems_version: 4.0.16
|
|
160
|
+
specification_version: 4
|
|
161
|
+
summary: Run Ruby graphics in the browser
|
|
162
|
+
test_files: []
|