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 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
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-09-25
4
+
5
+ Initial release
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
@@ -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
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webvas
4
+ class Scheduler
5
+ def initialize(bridge: Bridge.new)
6
+ @bridge = bridge
7
+ end
8
+
9
+ def request_animation_frame(callback)
10
+ @bridge.request_animation_frame(callback)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webvas
4
+ VERSION = "0.1.0"
5
+ 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: []