diff --git a/build.zig b/build.zig
index 1ec07ee6c..04a2f6f93 100644
--- a/build.zig
+++ b/build.zig
@@ -995,6 +995,15 @@ pub fn buildBackend(
// Examples, must be compiled for wasm32
{
+ const standalone_export_symbol_names = &[_][]const u8{
+ "main",
+ "add_event",
+ "arena_u8",
+ "gpa_u8",
+ "gpa_free",
+ "new_font",
+ };
+
var wasm_dvui_opts = DvuiModuleOptions{
.b = b,
.target = b.resolveTargetQuery(.{
@@ -1024,15 +1033,29 @@ pub fn buildBackend(
});
web_mod_wasm.export_symbol_names = export_symbol_names;
+ const web_standalone_mod_wasm = b.createModule(.{
+ .root_source_file = b.path("src/backends/web.zig"),
+ });
+ web_standalone_mod_wasm.export_symbol_names = standalone_export_symbol_names;
+
const dvui_web_wasm = addDvuiModule("dvui_web_wasm", wasm_dvui_opts);
+ const dvui_web_standalone_wasm = addDvuiModule("dvui_web_standalone_wasm", wasm_dvui_opts);
linkBackend(dvui_web_wasm, web_mod_wasm);
- const example_opts: ExampleOptions = .{
+ linkBackend(dvui_web_standalone_wasm, web_standalone_mod_wasm);
+
+ const app_example_opts: ExampleOptions = .{
.dvui_mod = dvui_web_wasm,
.backend_name = "web-backend",
.backend_mod = web_mod_wasm,
};
- addWebExample("web-test", b.path("examples/web-test.zig"), example_opts, wasm_dvui_opts, web_serve_exe);
- addWebExample("web-app", b.path("examples/app.zig"), example_opts, wasm_dvui_opts, web_serve_exe);
+ const standalone_example_opts: ExampleOptions = .{
+ .dvui_mod = dvui_web_standalone_wasm,
+ .backend_name = "web-backend",
+ .backend_mod = web_standalone_mod_wasm,
+ };
+ addWebExample("web-test", b.path("examples/web-test.zig"), app_example_opts, wasm_dvui_opts, web_serve_exe);
+ addWebExample("web-app", b.path("examples/app.zig"), app_example_opts, wasm_dvui_opts, web_serve_exe);
+ addWebStandaloneExample("web-standalone", b.path("examples/web-standalone.zig"), standalone_example_opts, wasm_dvui_opts);
}
},
.wio => {
@@ -1615,6 +1638,8 @@ fn addWebExample(
compile_step.dependOn(&b.addInstallFileWithDir(output, install_dir, "index.html").step);
const web_js = b.path("src/backends/web.js");
compile_step.dependOn(&b.addInstallFileWithDir(web_js, install_dir, "web.js").step);
+ const web_common = b.path("src/backends/web-common.js");
+ compile_step.dependOn(&b.addInstallFileWithDir(web_common, install_dir, "web-common.js").step);
b.addNamedLazyPath("web.js", web_js);
compile_step.dependOn(&install_wasm.step);
compile_step.dependOn(&install_noto.step);
@@ -1629,6 +1654,54 @@ fn addWebExample(
b.getInstallStep().dependOn(compile_step);
}
+fn addWebStandaloneExample(
+ comptime name: []const u8,
+ file: std.Build.LazyPath,
+ example_opts: ExampleOptions,
+ opts: DvuiModuleOptions,
+) void {
+ const b = opts.b;
+
+ const exeOptions: std.Build.ExecutableOptions = .{
+ .name = "web",
+ .root_module = b.createModule(.{
+ .root_source_file = file,
+ .target = opts.target,
+ .optimize = opts.optimize,
+ .link_libc = false,
+ .strip = if (opts.optimize == .ReleaseFast or opts.optimize == .ReleaseSmall) true else false,
+ }),
+ };
+ const web_exe = b.addExecutable(exeOptions);
+ web_exe.entry = .disabled;
+ web_exe.root_module.addImport("dvui", example_opts.dvui_mod);
+ web_exe.root_module.addImport(example_opts.backend_name, example_opts.backend_mod);
+
+ const web_check = b.addExecutable(exeOptions);
+ web_check.entry = .disabled;
+ web_check.root_module.addImport("dvui", example_opts.dvui_mod);
+ web_check.root_module.addImport(example_opts.backend_name, example_opts.backend_mod);
+ if (opts.check_step) |step| step.dependOn(&web_check.step);
+
+ const install_dir: std.Build.InstallDir = .{ .custom = "bin/" ++ name };
+
+ const install_wasm = b.addInstallArtifact(web_exe, .{
+ .dest_dir = .{ .override = install_dir },
+ });
+
+ const install_noto = b.addInstallFileWithDir(b.path("src/fonts/NotoSansKR-Regular.ttf"), install_dir, "NotoSansKR-Regular.ttf");
+
+ const compile_step = b.step(name, "Compile " ++ name ++ " (Web Worker standalone)");
+ compile_step.dependOn(&b.addInstallFileWithDir(b.path("src/backends/index-standalone.html"), install_dir, "index.html").step);
+ compile_step.dependOn(&b.addInstallFileWithDir(b.path("src/backends/web-standalone.js"), install_dir, "web-standalone.js").step);
+ compile_step.dependOn(&b.addInstallFileWithDir(b.path("src/backends/web-common.js"), install_dir, "web-common.js").step);
+ compile_step.dependOn(&b.addInstallFileWithDir(b.path("src/backends/web-worker.js"), install_dir, "web-worker.js").step);
+ compile_step.dependOn(&install_wasm.step);
+ compile_step.dependOn(&install_noto.step);
+
+ b.getInstallStep().dependOn(compile_step);
+}
+
/// Given a lazy path to an svg file, e.g. `b.path('./src/image.svg')`
/// return a LazyPath containing the generated tvg bytes. You can use this
/// to make icons for DVUI apps from SVGs at build time
diff --git a/examples/web-standalone.zig b/examples/web-standalone.zig
new file mode 100644
index 000000000..822ed8ff5
--- /dev/null
+++ b/examples/web-standalone.zig
@@ -0,0 +1,223 @@
+const std = @import("std");
+const dvui = @import("dvui");
+const WebBackend = @import("web-backend");
+
+comptime {
+ std.debug.assert(@hasDecl(WebBackend, "WebBackend"));
+}
+
+const window_icon_png = @embedFile("zig-favicon.png");
+
+const gpa: std.mem.Allocator = std.heap.wasm_allocator;
+
+const vsync = true;
+const show_demo = false;
+var scale_val: f32 = 1.0;
+
+var show_dialog_outside_frame: bool = false;
+var g_initialized = false;
+var g_interrupted = true;
+
+pub const panic = WebBackend.panic;
+pub const std_options: std.Options = .{
+ .logFn = WebBackend.logFn,
+};
+
+/// This example matches the SDL standalone structure:
+/// - explicit backend and window setup
+/// - explicit frame loop
+/// - explicit event pump and wait
+pub export fn main() void {
+ standaloneInit(null, 0) catch |err| {
+ std.log.err("Error during standalone init: {any}", .{err});
+ standaloneDeinit();
+ return;
+ };
+ defer standaloneDeinit();
+
+ main_loop: while (true) {
+ const wait_event_micros = (frameStep() catch |err| {
+ std.log.err("Error during frame step: {any}", .{err});
+ standaloneDeinit();
+ return;
+ }) orelse break :main_loop;
+ g_interrupted = try WebBackend.back.waitEventTimeout(wait_event_micros);
+ }
+}
+
+fn standaloneInit(platform_ptr: ?[*]const u8, platform_len: usize) !void {
+ _ = platform_ptr;
+ _ = platform_len;
+ if (g_initialized) return;
+
+ dvui.Examples.show_demo_window = show_demo;
+
+ WebBackend.back = try WebBackend.initWindow(.{
+ .allocator = gpa,
+ .size = .{ .w = 800.0, .h = 600.0 },
+ .min_size = .{ .w = 250.0, .h = 350.0 },
+ .vsync = vsync,
+ .title = "DVUI Web Standalone Example",
+ .icon = window_icon_png,
+ });
+
+ WebBackend.win = try dvui.Window.init(@src(), gpa, WebBackend.back.backend(), .{
+ .theme = switch (WebBackend.back.preferredColorScheme() orelse .light) {
+ .light => dvui.Theme.builtin.adwaita_light,
+ .dark => dvui.Theme.builtin.adwaita_dark,
+ },
+ });
+
+ WebBackend.win_ok = true;
+
+ g_interrupted = true;
+ g_initialized = true;
+}
+
+/// Execute one standalone frame.
+/// Returns micros to wait before next frame, or null when app should close.
+fn frameStep() !?u32 {
+ if (!g_initialized) return null;
+
+ var win_ref = &WebBackend.win;
+
+ const nstime = win_ref.beginWait(g_interrupted);
+ try win_ref.begin(nstime);
+
+ const keep_running = gui_frame();
+ if (!keep_running) return null;
+
+ const end_micros = try win_ref.end(.{});
+
+ if (show_dialog_outside_frame) {
+ show_dialog_outside_frame = false;
+ dvui.dialog(
+ @src(),
+ .{},
+ .{
+ .window = win_ref,
+ .modal = false,
+ .title = "Dialog from Outside",
+ .message = "This is a non modal dialog created outside win.begin()/win.end().",
+ },
+ );
+ }
+
+ return win_ref.waitTime(end_micros);
+}
+
+fn standaloneDeinit() void {
+ if (!g_initialized) return;
+ WebBackend.win_ok = false;
+ WebBackend.win.deinit();
+ WebBackend.back.deinit();
+ g_initialized = false;
+}
+
+// both dvui and backend drawing
+// return false if user wants to exit the app
+fn gui_frame() bool {
+ {
+ var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .style = .window, .background = true, .expand = .horizontal, .name = "main" });
+ defer hbox.deinit();
+
+ var m = dvui.menu(@src(), .horizontal, .{});
+ defer m.deinit();
+
+ if (dvui.menuItemLabel(@src(), "File", .{ .submenu = true }, .{})) |r| {
+ var fw = dvui.floatingMenu(@src(), .{ .from = r }, .{});
+ defer fw.deinit();
+
+ if (dvui.menuItemLabel(@src(), "Close Menu", .{}, .{ .expand = .horizontal }) != null) {
+ m.close();
+ }
+
+ if (dvui.menuItemLabel(@src(), "Exit", .{}, .{ .expand = .horizontal }) != null) {
+ return false;
+ }
+ }
+
+ if (dvui.menuItemLabel(@src(), "Edit", .{ .submenu = true }, .{})) |r| {
+ var fw = dvui.floatingMenu(@src(), .{ .from = r }, .{});
+ defer fw.deinit();
+ _ = dvui.menuItemLabel(@src(), "Dummy", .{}, .{ .expand = .horizontal });
+ _ = dvui.menuItemLabel(@src(), "Dummy Long", .{}, .{ .expand = .horizontal });
+ _ = dvui.menuItemLabel(@src(), "Dummy Super Long", .{}, .{ .expand = .horizontal });
+ }
+ }
+
+ var scroll = dvui.scrollArea(@src(), .{}, .{ .expand = .both });
+ defer scroll.deinit();
+
+ var tl = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .font = .theme(.title) });
+ const lorem = "This example shows how to use dvui in a normal standalone application on the web.";
+ tl.addText(lorem, .{});
+ tl.deinit();
+
+ var tl2 = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal });
+ tl2.addText(
+ \\DVUI
+ \\- paints the entire window
+ \\- can show floating windows and dialogs
+ \\- example menu at the top of the window
+ \\- rest of the window is a scroll area
+ \\
+ \\
+ , .{});
+ tl2.addText("Framerate is variable and adjusts as needed for input events and animations.\n\n", .{});
+ if (vsync) {
+ tl2.addText("Framerate is capped by vsync.\n", .{});
+ } else {
+ tl2.addText("Framerate is uncapped.\n", .{});
+ }
+ tl2.addText("\n", .{});
+ tl2.addText("Cursor is always being set by dvui.\n\n", .{});
+ if (dvui.useFreeType) {
+ tl2.addText("Fonts are being rendered by FreeType 2.", .{});
+ } else {
+ tl2.addText("Fonts are being rendered by stb_truetype.", .{});
+ }
+ tl2.deinit();
+
+ const label = if (dvui.Examples.show_demo_window) "Hide Demo Window" else "Show Demo Window";
+ if (dvui.button(@src(), label, .{}, .{})) {
+ dvui.Examples.show_demo_window = !dvui.Examples.show_demo_window;
+ }
+
+ if (dvui.button(@src(), "Debug Window", .{}, .{})) {
+ dvui.toggleDebugWindow();
+ }
+
+ {
+ var scaler = dvui.scale(@src(), .{ .scale = &scale_val }, .{ .expand = .horizontal });
+ defer scaler.deinit();
+
+ {
+ var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{});
+ defer hbox.deinit();
+
+ if (dvui.button(@src(), "Zoom In", .{}, .{})) {
+ scale_val = @round(dvui.themeGet().font_body.size * scale_val + 1.0) / dvui.themeGet().font_body.size;
+ }
+
+ if (dvui.button(@src(), "Zoom Out", .{}, .{})) {
+ scale_val = @round(dvui.themeGet().font_body.size * scale_val - 1.0) / dvui.themeGet().font_body.size;
+ }
+ }
+
+ dvui.labelNoFmt(@src(), "Backend-direct drawing demo is SDL-specific and omitted for web.", .{}, .{ .margin = .{ .x = 4 } });
+ }
+
+ if (dvui.button(@src(), "Show Dialog From\nOutside Frame", .{}, .{})) {
+ show_dialog_outside_frame = true;
+ }
+
+ dvui.Examples.demo(.full);
+
+ for (dvui.events()) |*e| {
+ if (e.evt == .window and e.evt.window.action == .close) return false;
+ if (e.evt == .app and e.evt.app.action == .quit) return false;
+ }
+
+ return true;
+}
diff --git a/src/backends/index-standalone.html b/src/backends/index-standalone.html
new file mode 100644
index 000000000..7a7ddc00a
--- /dev/null
+++ b/src/backends/index-standalone.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+ DVUI Web Standalone (Worker)
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/backends/web-common.js b/src/backends/web-common.js
new file mode 100644
index 000000000..c4db17b60
--- /dev/null
+++ b/src/backends/web-common.js
@@ -0,0 +1,1092 @@
+/**
+ * Fetch a url and return its body as bytes.
+ * @param {string} url
+ * @returns {Promise}
+ */
+export async function dvui_fetch(url) {
+ let x = await fetch(url);
+ let blob = await x.blob();
+ //console.log("dvui_fetch: " + blob.size);
+ return new Uint8Array(await blob.arrayBuffer());
+}
+
+export const vertexShaderSource_webgl = `
+ precision mediump float;
+
+ attribute vec4 aVertexPosition;
+ attribute vec4 aVertexColor;
+ attribute vec2 aTextureCoord;
+
+ uniform mat4 uMatrix;
+
+ varying vec4 vColor;
+ varying vec2 vTextureCoord;
+
+ void main() {
+ gl_Position = uMatrix * aVertexPosition;
+ vColor = aVertexColor / 255.0; // normalize u8 colors to 0-1
+ vTextureCoord = aTextureCoord;
+ }
+`;
+
+export const vertexShaderSource_webgl2 = `# version 300 es
+
+ precision mediump float;
+
+ in vec4 aVertexPosition;
+ in vec4 aVertexColor;
+ in vec2 aTextureCoord;
+
+ uniform mat4 uMatrix;
+
+ out vec4 vColor;
+ out vec2 vTextureCoord;
+
+ void main() {
+ gl_Position = uMatrix * aVertexPosition;
+ vColor = aVertexColor / 255.0; // normalize u8 colors to 0-1
+ vTextureCoord = aTextureCoord;
+ }
+`;
+
+export const fragmentShaderSource_webgl = `
+ precision mediump float;
+
+ varying vec4 vColor;
+ varying vec2 vTextureCoord;
+
+ uniform sampler2D uSampler;
+ uniform bool useTex;
+
+ void main() {
+ if (useTex) {
+ gl_FragColor = texture2D(uSampler, vTextureCoord) * vColor;
+ }
+ else {
+ gl_FragColor = vColor;
+ }
+ }
+`;
+
+export const fragmentShaderSource_webgl2 = `# version 300 es
+
+ precision mediump float;
+
+ in vec4 vColor;
+ in vec2 vTextureCoord;
+
+ uniform sampler2D uSampler;
+ uniform bool useTex;
+
+ out vec4 fragColor;
+
+ void main() {
+ if (useTex) {
+ fragColor = texture(uSampler, vTextureCoord) * vColor;
+ }
+ else {
+ fragColor = vColor;
+ }
+ }
+`;
+
+export const utf8decoder = new TextDecoder();
+export const utf8encoder = new TextEncoder();
+
+/// Shared memory layout protocol between main thread and worker for standalone mode.
+
+/// Protocol for SharedArrayBuffer layout (Total Size: 9,472 bytes):
+/// Int32[0] = signal flag (Atomics.wait/notify)
+/// Int32[1] = event write cursor (main thread writes, worker reads)
+/// Int32[2] = event read cursor (worker writes, main thread reads)
+/// Int32[3] = preferred color scheme (0 = system/unknown, 1 = dark, 2 = light)
+/// Float32[4..7] = canvas info (byte offset 16):
+/// Float32[4] = pixel width
+/// Float32[5] = pixel height
+/// Float32[6] = canvas (CSS) width
+/// Float32[7] = canvas (CSS) height
+/// Bytes[256..5375] = event ring buffer (EVENT_RING_OFFSET, size: 5,120 bytes)
+/// Bytes[5376..9471] = string storage area (STRING_AREA_OFFSET, size: 4,096 bytes)
+///
+/// Each event in the ring is 20 bytes:
+/// u8 kind, 3 bytes padding, u32 int1, u32 int2, f32 float1, f32 float2
+
+export const SIGNAL_INDEX = 0;
+export const WRITE_CURSOR_INDEX = 1;
+export const READ_CURSOR_INDEX = 2;
+export const COLOR_SCHEME_INDEX = 3;
+
+export const CANVAS_INFO_OFFSET = 16;
+export const EVENT_RING_OFFSET = 256;
+export const EVENT_SIZE = 20;
+export const MAX_EVENTS = 256;
+
+export const RING_SIZE = EVENT_SIZE * MAX_EVENTS;
+export const STRING_AREA_OFFSET = EVENT_RING_OFFSET + RING_SIZE;
+export const STRING_AREA_SIZE = 4096;
+export const TOTAL_SHARED_SIZE = STRING_AREA_OFFSET + STRING_AREA_SIZE;
+
+/**
+ * Encode modifier keys into a 4-bit value.
+ * @param {KeyboardEvent | MouseEvent} ev
+ * @returns {number}
+ */
+export function encodeModifiers(ev) {
+ return (ev.metaKey << 3) + (ev.altKey << 2) + (ev.ctrlKey << 1) + (ev.shiftKey << 0);
+}
+
+/**
+ * Return the existing touch index for pointerId, allocating a new one if needed.
+ * @param {[number, number][]} touches
+ * @param {number} pointerId
+ * @returns {number}
+ */
+export function touchIndex(touches, pointerId) {
+ let idx = touches.findIndex((e) => e[0] === pointerId);
+ if (idx < 0) {
+ idx = touches.length;
+ touches.push([pointerId, idx]);
+ }
+ return idx;
+}
+
+/**
+ * Tracks wheel/touchpad scroll deltas and produces normalized tick values.
+ */
+export class WheelHandler {
+ /** The lowest deltaX/Y seen, used to determine the delta for touchpads
+ *
+ * The first number is x and second is y
+ * @type {[number, number]} */
+ scrollLowest = [99999, 99999];
+ /** The lowest deltaX/Y seen in this batch (resets if none in 1s). Used to
+ * determine if we think a touchpad is being used and also as the delta for
+ * mouse wheels.
+ *
+ * The first number is x and second is y
+ * @type {[number, number]} */
+ scrollLowestBatch = [99999, 99999];
+ scrollLastMs = Date.now();
+ touchpadAdj = 0.025;
+
+ /**
+ * Process a WheelEvent and return scroll actions.
+ * @param {WheelEvent} ev
+ * @returns {{axis: number, ticks: number, trackpad: number}[]}
+ */
+ processWheelEvent(ev) {
+ const actions = [];
+
+ if ((Date.now() - this.scrollLastMs) > 1000) {
+ this.scrollLowestBatch[0] = 99999;
+ this.scrollLowestBatch[1] = 99999;
+ }
+ this.scrollLastMs = Date.now();
+
+ if (ev.deltaX !== 0) {
+ const result = this._processAxis(0, ev.deltaX, ev.deltaMode);
+ if (result) actions.push({ axis: 0, ...result });
+ }
+ if (ev.deltaY !== 0) {
+ const result = this._processAxis(1, ev.deltaY, ev.deltaMode);
+ if (result) actions.push({ axis: 1, ...result });
+ }
+
+ return actions;
+ }
+
+ _processAxis(index, delta, deltaMode) {
+ const absDelta = Math.abs(delta);
+ this.scrollLowest[index] = Math.min(absDelta, this.scrollLowest[index]);
+ this.scrollLowestBatch[index] = Math.min(absDelta, this.scrollLowestBatch[index]);
+
+ let ticks = -delta;
+ let trackpad = 0;
+
+ if (deltaMode !== 0) {
+ // only mouse wheels produce non-pixel deltas, so this is definitive without
+ // needing the magnitude heuristic.
+ ticks /= this.scrollLowestBatch[index];
+ } else if (
+ this.scrollLowestBatch[index] >= 100 || // most wheels
+ this.scrollLowestBatch[index] === 16 || // mac firefox
+ (index === 0 && (
+ this.scrollLowestBatch[index] === 9 || // mac firefox holding shift
+ this.scrollLowestBatch[index] === 40 // mac safari/chrome holding shift
+ )) ||
+ this.scrollLowestBatch[index] === 4.000244140625 // mac safari/chrome
+ ) {
+ // assume this is a mouse wheel
+ ticks /= this.scrollLowestBatch[index];
+ if (this.scrollLowestBatch[index] === 4.000244140625) {
+ ticks *= this.touchpadAdj; // mac safari/chrome scale wheel like touchpad
+ }
+ } else {
+ // assume touchpad
+ trackpad = 1;
+ ticks = (ticks / this.scrollLowest[index]) * this.touchpadAdj;
+ }
+
+ return { ticks, trackpad };
+ }
+}
+
+//let par = document.createElement("p");
+//document.body.prepend(par);
+
+/**
+ * Manages a hidden input element for IME / on-screen keyboard support.
+ */
+export class HiddenInputManager {
+ /** @type {HTMLInputElement} */
+ hiddenInput;
+ /**
+ * x y w h of on screen keyboard editing position, or empty if none
+ *
+ * @type {[number, number, number, number] | []} */
+ textInputRect = [];
+ /** @type {HTMLElement} */
+ target;
+
+ /**
+ * @param {HTMLElement} target - Element to position relative to (typically canvas)
+ */
+ constructor(target) {
+ this.target = target;
+ this.hiddenInput = document.createElement("input");
+ this.hiddenInput.setAttribute("autocapitalize", "none");
+ this.hiddenInput.style.position = "absolute";
+ this.hiddenInput.style.left = "0";
+ this.hiddenInput.style.top = "0";
+ // remove extra size so input doesn't cause overflow
+ this.hiddenInput.style.padding = "0";
+ this.hiddenInput.style.border = "0";
+ this.hiddenInput.style.margin = "0";
+ this.hiddenInput.style.opacity = "0";
+ this.hiddenInput.style.zIndex = "-1";
+ document.body.prepend(this.hiddenInput);
+ }
+
+ /**
+ * Set the on screen keyboard editing position, or empty if none.
+ * @param {[number, number, number, number] | []} rect - x y w h
+ */
+ setRect(rect) {
+ if (rect.length === 4 && rect[2] > 0 && rect[3] > 0) {
+ this.textInputRect = rect;
+ } else {
+ this.textInputRect = [];
+ }
+ }
+
+ // This does 2 things:
+ // * on desktop it's needed for us to get text events (not just char down/up)
+ // * on touch it's needed to show the on screen keyboard
+ check() {
+ if (this.textInputRect.length === 0) {
+ this.target.focus();
+ } else {
+ const rect = this.target.getBoundingClientRect();
+ const left = window.scrollX + rect.left + this.textInputRect[0];
+ const top = window.scrollY + rect.top + this.textInputRect[1];
+ // limit the width and height to prevent overflow
+ const width = Math.max(0, Math.min(this.textInputRect[2], this.target.clientWidth - this.textInputRect[0]));
+ const height = Math.max(0, Math.min(this.textInputRect[3], this.target.clientHeight - this.textInputRect[1]));
+ this.hiddenInput.style.left = left + "px";
+ this.hiddenInput.style.top = top + "px";
+ this.hiddenInput.style.width = width + "px";
+ this.hiddenInput.style.height = height + "px";
+ this.hiddenInput.focus();
+ //par.textContent = hiddenInput.style.left + " " + hiddenInput.style.top + " " + hiddenInput.style.width + " " + hiddenInput.style.height;
+ }
+ }
+}
+
+/**
+ * Normalize touch coordinates relative to an element's bounding rect.
+ * @param {Touch} touch
+ * @param {DOMRect} rect
+ * @returns {[number, number]}
+ */
+export function getTouchCoords(touch, rect) {
+ return [
+ (touch.clientX - rect.left) / (rect.right - rect.left),
+ (touch.clientY - rect.top) / (rect.bottom - rect.top),
+ ];
+}
+
+export class WebRenderer {
+ /** @type {WebGL2RenderingContext | WebGLRenderingContext | null} */
+ gl = null;
+ /** @type {WebGLBuffer | null} */
+ indexBuffer = null;
+ /** @type {WebGLBuffer | null} */
+ vertexBuffer = null;
+ /** @type {WebGLProgram | null} */
+ shaderProgram = null;
+ /** @type {{ attribLocations: { vertexPosition: number;
+ vertexColor: number;
+ textureCoord: number;
+ };
+ uniformLocations: {
+ matrix: WebGLUniformLocation | null;
+ uSampler: WebGLUniformLocation | null;
+ useTex: WebGLUniformLocation | null;
+ };
+ } | null} */
+ programInfo = null;
+ /** @type {Map} */
+ textures = new Map();
+ newTextureId = 1;
+
+ /** @returns {[WebGLTexture, number, number] | null} */
+ textureEntry(id) {
+ if (id === 0) return null;
+ return this.textures.get(id) ?? null;
+ }
+
+ using_fb = false;
+ /** @type {WebGLFramebuffer | null} */
+ frame_buffer = null;
+ /** @type {[number, number]} */
+ renderTargetSize = [0, 0];
+
+ /** @type {WebAssembly.Instance | null} */
+ instance = null;
+
+ console_string = "";
+
+ get webgl2() {
+ return this.gl instanceof WebGL2RenderingContext;
+ }
+
+ /**
+ * @param {DVUI.AllocatorFunction} allocFn
+ * @param {number} len
+ * @returns {[pointer: number, slice: Uint8Array]}
+ */
+ genericAlloc(allocFn, len) {
+ const pointer = allocFn(len);
+ const slice = new Uint8Array(
+ this.instance.exports.memory.buffer,
+ pointer,
+ len);
+ return [pointer, slice];
+ }
+
+ /**
+ * @param {DVUI.AllocatorFunction} allocFn
+ * @param {ArrayLike} bytes
+ * @returns {number} pointer
+ */
+ allocBuffer(allocFn, bytes) {
+ const [pointer, slice] = this.genericAlloc(allocFn, bytes.length);
+ slice.set(bytes);
+ return pointer;
+ }
+
+ /**
+ * @param {DVUI.AllocatorFunction} allocFn
+ * @param {ArrayLike} bytes
+ * @param {number} sentinel
+ * @returns {number} pointer
+ */
+ allocBufferZ(allocFn, bytes, sentinel = 0) {
+ const [pointer, slice] = this.genericAlloc(allocFn, bytes.length + 1);
+ slice.set(bytes);
+ slice[bytes.length] = sentinel;
+ return pointer;
+ }
+
+ /**
+ * @param {DVUI.AllocatorFunction} allocFn
+ * @param {string} string
+ * @returns {number} pointer
+ */
+ allocString(allocFn, string) {
+ const buffer = utf8encoder.encode(string);
+ return this.allocBuffer(allocFn, buffer);
+ }
+
+ /**
+ * @param {DVUI.AllocatorFunction} allocFn
+ * @param {string} string
+ * @param {number} sentinel
+ * @returns {number} pointer
+ */
+ allocStringZ(allocFn, string, sentinel = 0) {
+ const buffer = utf8encoder.encode(string);
+ return this.allocBufferZ(allocFn, buffer, sentinel);
+ }
+
+ /**
+ * @param {number} ptr
+ * @param {number} length
+ * @returns {string}
+ */
+ stringFromPointer(ptr, length) {
+ return utf8decoder.decode(this.bytesFromPointer(ptr, length));
+ }
+
+ /**
+ * @param {number} ptr
+ * @param {number} length
+ * @returns {Uint8Array}
+ */
+ bytesFromPointer(ptr, length) {
+ return new Uint8Array(this.instance.exports.memory.buffer, ptr, length);
+ }
+
+ buildImports() {
+ const names = [
+ "wasm_about_webgl2",
+ "wasm_console_drain",
+ "wasm_console_flush",
+ "wasm_now",
+ "wasm_frame_buffer",
+ "wasm_pixel_width",
+ "wasm_pixel_height",
+ "wasm_canvas_width",
+ "wasm_canvas_height",
+ "wasm_textureCreate",
+ "wasm_textureCreateTarget",
+ "wasm_textureClearTarget",
+ "wasm_textureRead",
+ "wasm_renderTarget",
+ "wasm_textureDestroy",
+ "wasm_renderGeometry",
+ "wasm_download_data",
+ "wasm_panic",
+ "wasm_sleep",
+ "wasm_refresh",
+ "wasm_cursor",
+ "wasm_text_input",
+ "wasm_open_url",
+ "wasm_preferred_color_scheme",
+ "wasm_prefers_reduced_motion",
+ "wasm_open_file_picker",
+ "wasm_get_file_size",
+ "wasm_get_file_name",
+ "wasm_read_file_data",
+ "wasm_get_number_of_files_available",
+ "wasm_clipboardTextSet",
+ "wasm_add_noto_font",
+ "wasm_canvas_info",
+ "wasm_wait_event",
+ "wasm_send_offscreencanvas_bitmap",
+ ];
+ const imports = {};
+ for (const name of names) {
+ imports[name] = (...args) => this[name](...args);
+ }
+ return imports;
+ }
+
+ setInstance(instance) {
+ this.instance = instance;
+ }
+
+ /**
+ * @param {HTMLCanvasElement | OffscreenCanvas} canvas
+ * @returns {WebGLProgram | null}
+ */
+ setupWebGL(canvas) {
+ this.gl = canvas.getContext("webgl2", { alpha: true, antialias: false });
+ if (this.gl === null) {
+ this.gl = canvas.getContext("webgl", { alpha: true, antialias: false });
+ }
+ if (this.gl === null) {
+ console.error("Unable to initialize WebGL.");
+ return null;
+ }
+
+ this.frame_buffer = this.gl.createFramebuffer();
+
+ const gl = this.gl;
+ const vertexShader = gl.createShader(gl.VERTEX_SHADER);
+ gl.shaderSource(vertexShader, this.webgl2 ? vertexShaderSource_webgl2 : vertexShaderSource_webgl);
+ gl.compileShader(vertexShader);
+ if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
+ console.error("Vertex shader error:", gl.getShaderInfoLog(vertexShader));
+ return null;
+ }
+
+ const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
+ gl.shaderSource(fragmentShader, this.webgl2 ? fragmentShaderSource_webgl2 : fragmentShaderSource_webgl);
+ gl.compileShader(fragmentShader);
+ if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
+ console.error("Fragment shader error:", gl.getShaderInfoLog(fragmentShader));
+ return null;
+ }
+
+ this.shaderProgram = gl.createProgram();
+ gl.attachShader(this.shaderProgram, vertexShader);
+ gl.attachShader(this.shaderProgram, fragmentShader);
+ gl.linkProgram(this.shaderProgram);
+ if (!gl.getProgramParameter(this.shaderProgram, gl.LINK_STATUS)) {
+ console.error("Shader link error:", gl.getProgramInfoLog(this.shaderProgram));
+ return null;
+ }
+
+ this.programInfo = {
+ attribLocations: {
+ vertexPosition: gl.getAttribLocation(this.shaderProgram, "aVertexPosition"),
+ vertexColor: gl.getAttribLocation(this.shaderProgram, "aVertexColor"),
+ textureCoord: gl.getAttribLocation(this.shaderProgram, "aTextureCoord"),
+ },
+ uniformLocations: {
+ matrix: gl.getUniformLocation(this.shaderProgram, "uMatrix"),
+ uSampler: gl.getUniformLocation(this.shaderProgram, "uSampler"),
+ useTex: gl.getUniformLocation(this.shaderProgram, "useTex"),
+ },
+ };
+
+ this.indexBuffer = gl.createBuffer();
+ this.vertexBuffer = gl.createBuffer();
+
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
+ gl.enable(gl.SCISSOR_TEST);
+ return this.shaderProgram;
+ }
+
+ wasm_about_webgl2() {
+ if (this.webgl2) {
+ return 1;
+ } else {
+ return 0;
+ }
+ }
+
+ wasm_panic(ptr, len) {
+ const msg = this.stringFromPointer(ptr, len);
+ console.error("PANIC:", msg);
+ }
+
+ wasm_console_drain(ptr, len) {
+ this.console_string += this.stringFromPointer(ptr, len);
+ }
+
+ wasm_console_flush(level) {
+ switch (level) {
+ case 9:
+ console.error(this.console_string);
+ break;
+ case 7:
+ console.warn(this.console_string);
+ break;
+ case 5:
+ console.info(this.console_string);
+ break;
+ case 3:
+ console.debug(this.console_string);
+ break;
+ default:
+ console.log(this.console_string);
+ break;
+ }
+ this.console_string = "";
+ }
+
+ wasm_now() {
+ return performance.now();
+ }
+
+ wasm_sleep(_ms) { }
+
+ wasm_refresh() { }
+
+ wasm_pixel_width() {
+ return this.gl.drawingBufferWidth;
+ }
+
+ wasm_pixel_height() {
+ return this.gl.drawingBufferHeight;
+ }
+
+ wasm_frame_buffer() {
+ if (this.using_fb) {
+ return 1;
+ } else {
+ return 0;
+ }
+ }
+
+ wasm_canvas_width() {
+ return this.gl.canvas.clientWidth;
+ }
+
+ wasm_canvas_height() {
+ return this.gl.canvas.clientHeight;
+ }
+
+ wasm_textureCreate(pixels, width, height, interp, wrap_u, wrap_v) {
+ const pixelData = this.bytesFromPointer(pixels, width * height * 4);
+
+ const texture = this.gl.createTexture();
+ const id = this.newTextureId;
+ //console.log("creating texture " + id);
+ this.newTextureId += 1;
+ this.textures.set(id, [texture, width, height]);
+
+ this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
+
+ this.gl.texImage2D(
+ this.gl.TEXTURE_2D,
+ 0,
+ this.gl.RGBA,
+ width,
+ height,
+ 0,
+ this.gl.RGBA,
+ this.gl.UNSIGNED_BYTE,
+ pixelData,
+ );
+
+ if (this.webgl2) {
+ this.gl.generateMipmap(this.gl.TEXTURE_2D);
+ }
+
+ if (interp == 0) {
+ this.gl.texParameteri(
+ this.gl.TEXTURE_2D,
+ this.gl.TEXTURE_MIN_FILTER,
+ this.gl.NEAREST,
+ );
+ this.gl.texParameteri(
+ this.gl.TEXTURE_2D,
+ this.gl.TEXTURE_MAG_FILTER,
+ this.gl.NEAREST,
+ );
+ } else {
+ this.gl.texParameteri(
+ this.gl.TEXTURE_2D,
+ this.gl.TEXTURE_MIN_FILTER,
+ this.gl.LINEAR,
+ );
+ this.gl.texParameteri(
+ this.gl.TEXTURE_2D,
+ this.gl.TEXTURE_MAG_FILTER,
+ this.gl.LINEAR,
+ );
+ }
+ if (wrap_u === 1) {
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.REPEAT);
+ } else {
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
+ }
+ if (wrap_v === 1) {
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.REPEAT);
+ } else {
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
+ }
+
+ this.gl.bindTexture(this.gl.TEXTURE_2D, null);
+
+ return id;
+ }
+
+ wasm_textureCreateTarget(width, height, interp, wrap_u, wrap_v) {
+ const texture = this.gl.createTexture();
+ const id = this.newTextureId;
+ //console.log("creating texture " + id);
+ this.newTextureId += 1;
+ this.textures.set(id, [texture, width, height]);
+
+ this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
+
+ this.gl.texImage2D(
+ this.gl.TEXTURE_2D,
+ 0,
+ this.gl.RGBA,
+ width,
+ height,
+ 0,
+ this.gl.RGBA,
+ this.gl.UNSIGNED_BYTE,
+ null,
+ );
+
+ if (interp == 0) {
+ this.gl.texParameteri(
+ this.gl.TEXTURE_2D,
+ this.gl.TEXTURE_MIN_FILTER,
+ this.gl.NEAREST,
+ );
+ this.gl.texParameteri(
+ this.gl.TEXTURE_2D,
+ this.gl.TEXTURE_MAG_FILTER,
+ this.gl.NEAREST,
+ );
+ } else {
+ this.gl.texParameteri(
+ this.gl.TEXTURE_2D,
+ this.gl.TEXTURE_MIN_FILTER,
+ this.gl.LINEAR,
+ );
+ this.gl.texParameteri(
+ this.gl.TEXTURE_2D,
+ this.gl.TEXTURE_MAG_FILTER,
+ this.gl.LINEAR,
+ );
+ }
+ if (wrap_u === 1) {
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.REPEAT);
+ } else {
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
+ }
+ if (wrap_v === 1) {
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.REPEAT);
+ } else {
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
+ }
+
+ this.gl.bindTexture(this.gl.TEXTURE_2D, null);
+
+ this.wasm_textureClearTarget(id);
+
+ return id;
+ }
+
+ wasm_textureClearTarget(textureId) {
+ this.wasm_renderTarget(textureId);
+ this.gl.clearColor(0.0, 0.0, 0.0, 0.0); // fully transparent
+ this.gl.clear(this.gl.COLOR_BUFFER_BIT);
+ this.wasm_renderTarget(0);
+ }
+
+ wasm_textureRead(textureId, pixels_out, width, height) {
+ //console.log("textureRead " + textureId);
+ const entry = this.textureEntry(textureId);
+ if (entry === null) {
+ console.warn(
+ `wasm_textureRead: missing texture id ${textureId}`,
+ );
+ return;
+ }
+ const texture = entry[0];
+
+ this.gl.bindFramebuffer(
+ this.gl.FRAMEBUFFER,
+ this.frame_buffer,
+ );
+ this.gl.framebufferTexture2D(
+ this.gl.FRAMEBUFFER,
+ this.gl.COLOR_ATTACHMENT0,
+ this.gl.TEXTURE_2D,
+ texture,
+ 0,
+ );
+
+ var dest = this.bytesFromPointer(pixels_out, width * height * 4);
+ this.gl.readPixels(
+ 0,
+ 0,
+ width,
+ height,
+ this.gl.RGBA,
+ this.gl.UNSIGNED_BYTE,
+ dest,
+ 0,
+ );
+
+ this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
+ }
+
+ wasm_renderTarget(id) {
+ //console.log("renderTarget " + id);
+ if (id === 0) {
+ this.using_fb = false;
+ this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
+ this.renderTargetSize = [
+ this.gl.drawingBufferWidth,
+ this.gl.drawingBufferHeight,
+ ];
+ this.gl.viewport(
+ 0,
+ 0,
+ this.renderTargetSize[0],
+ this.renderTargetSize[1],
+ );
+ this.gl.scissor(
+ 0,
+ 0,
+ this.renderTargetSize[0],
+ this.renderTargetSize[1],
+ );
+ } else {
+ this.using_fb = true;
+ this.gl.bindFramebuffer(
+ this.gl.FRAMEBUFFER,
+ this.frame_buffer,
+ );
+
+ const rt = this.textureEntry(id);
+ if (rt === null) {
+ console.warn(
+ `wasm_renderTarget: missing texture id ${id}`,
+ );
+ this.using_fb = false;
+ this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
+ this.renderTargetSize = [
+ this.gl.drawingBufferWidth,
+ this.gl.drawingBufferHeight,
+ ];
+ } else {
+ this.gl.framebufferTexture2D(
+ this.gl.FRAMEBUFFER,
+ this.gl.COLOR_ATTACHMENT0,
+ this.gl.TEXTURE_2D,
+ rt[0],
+ 0,
+ );
+ this.renderTargetSize = [rt[1], rt[2]];
+ }
+ this.gl.viewport(
+ 0,
+ 0,
+ this.renderTargetSize[0],
+ this.renderTargetSize[1],
+ );
+ this.gl.scissor(
+ 0,
+ 0,
+ this.renderTargetSize[0],
+ this.renderTargetSize[1],
+ );
+ }
+ }
+
+ wasm_textureDestroy(id) {
+ //console.log("deleting texture " + id);
+ const entry = this.textureEntry(id);
+ if (entry === null) return;
+ this.textures.delete(id);
+ this.gl.deleteTexture(entry[0]);
+ }
+
+ buildOrthoMatrix() {
+ const matrix = new Float32Array(16);
+ matrix[0] = 2.0 / this.renderTargetSize[0];
+ matrix[1] = 0.0;
+ matrix[2] = 0.0;
+ matrix[3] = 0.0;
+ matrix[4] = 0.0;
+ if (this.using_fb) {
+ matrix[5] = 2.0 / this.renderTargetSize[1];
+ } else {
+ matrix[5] = -2.0 / this.renderTargetSize[1];
+ }
+ matrix[6] = 0.0;
+ matrix[7] = 0.0;
+ matrix[8] = 0.0;
+ matrix[9] = 0.0;
+ matrix[10] = 1.0;
+ matrix[11] = 0.0;
+ matrix[12] = -1.0;
+ if (this.using_fb) {
+ matrix[13] = -1.0;
+ } else {
+ matrix[13] = 1.0;
+ }
+ matrix[14] = 0.0;
+ matrix[15] = 1.0;
+ return matrix;
+ }
+
+ wasm_renderGeometry(
+ textureId,
+ index_ptr,
+ index_len,
+ vertex_ptr,
+ vertex_len,
+ sizeof_vertex,
+ offset_pos,
+ offset_col,
+ offset_uv,
+ clip,
+ x,
+ y,
+ w,
+ h,
+ ) {
+ //console.log("drawClippedTriangles " + textureId + " sizeof " + sizeof_vertex + " pos " + offset_pos + " col " + offset_col + " uv " + offset_uv);
+
+ //let old_scissor;
+ if (clip === 1) {
+ // just calling getParameter here is quite slow (5-10 ms per frame according to chrome)
+ //old_scissor = gl.getParameter(gl.SCISSOR_BOX);
+ this.gl.scissor(x, y, w, h);
+ }
+
+ this.gl.bindBuffer(
+ this.gl.ELEMENT_ARRAY_BUFFER,
+ this.indexBuffer,
+ );
+ const indices = new Uint16Array(
+ this.instance.exports.memory.buffer,
+ index_ptr,
+ index_len / 2,
+ );
+ this.gl.bufferData(
+ this.gl.ELEMENT_ARRAY_BUFFER,
+ indices,
+ this.gl.DYNAMIC_DRAW,
+ );
+
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
+ const vertexes = this.bytesFromPointer(vertex_ptr, vertex_len)
+ this.gl.bufferData(
+ this.gl.ARRAY_BUFFER,
+ vertexes,
+ this.gl.DYNAMIC_DRAW,
+ );
+
+ let matrix = this.buildOrthoMatrix();
+
+ // vertex
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
+ this.gl.vertexAttribPointer(
+ this.programInfo.attribLocations.vertexPosition,
+ 2, // num components
+ this.gl.FLOAT,
+ false, // don't normalize
+ sizeof_vertex, // stride
+ offset_pos, // offset
+ );
+ this.gl.enableVertexAttribArray(
+ this.programInfo.attribLocations.vertexPosition,
+ );
+
+ // color
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
+ this.gl.vertexAttribPointer(
+ this.programInfo.attribLocations.vertexColor,
+ 4, // num components
+ this.gl.UNSIGNED_BYTE,
+ false, // don't normalize
+ sizeof_vertex, // stride
+ offset_col, // offset
+ );
+ this.gl.enableVertexAttribArray(
+ this.programInfo.attribLocations.vertexColor,
+ );
+
+ // texture
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
+ this.gl.vertexAttribPointer(
+ this.programInfo.attribLocations.textureCoord,
+ 2, // num components
+ this.gl.FLOAT,
+ false, // don't normalize
+ sizeof_vertex, // stride
+ offset_uv, // offset
+ );
+ this.gl.enableVertexAttribArray(
+ this.programInfo.attribLocations.textureCoord,
+ );
+
+ // Tell WebGL to use our program when drawing
+ this.gl.useProgram(this.shaderProgram);
+
+ // Set the shader uniforms
+ this.gl.uniformMatrix4fv(
+ this.programInfo.uniformLocations.matrix,
+ false,
+ matrix,
+ );
+
+ if (textureId != 0) {
+ const tex = this.textureEntry(textureId);
+ if (tex !== null) {
+ this.gl.activeTexture(this.gl.TEXTURE0);
+ this.gl.bindTexture(this.gl.TEXTURE_2D, tex[0]);
+ this.gl.uniform1i(
+ this.programInfo.uniformLocations.useTex,
+ 1,
+ );
+ } else {
+ console.warn(
+ `wasm_renderGeometry: missing texture id ${textureId}`,
+ );
+ this.gl.bindTexture(this.gl.TEXTURE_2D, null);
+ this.gl.uniform1i(
+ this.programInfo.uniformLocations.useTex,
+ 0,
+ );
+ }
+ } else {
+ this.gl.bindTexture(this.gl.TEXTURE_2D, null);
+ this.gl.uniform1i(
+ this.programInfo.uniformLocations.useTex,
+ 0,
+ );
+ }
+
+ this.gl.uniform1i(
+ this.programInfo.uniformLocations.uSampler,
+ 0,
+ );
+
+ //console.log("drawElements " + textureId);
+ this.gl.drawElements(
+ this.gl.TRIANGLES,
+ indices.length,
+ this.gl.UNSIGNED_SHORT,
+ 0,
+ );
+
+ if (clip === 1) {
+ //gl.scissor(old_scissor[0], old_scissor[1], old_scissor[2], old_scissor[3]);
+ this.gl.scissor(
+ 0,
+ 0,
+ this.renderTargetSize[0],
+ this.renderTargetSize[1],
+ );
+ }
+ }
+
+ wasm_cursor(_name_ptr, _name_len) { }
+
+ wasm_text_input(_x, _y, _w, _h) { }
+
+ wasm_open_url(_ptr, _len, _new_win) { }
+
+ wasm_preferred_color_scheme() { return 0; }
+
+ wasm_prefers_reduced_motion() { return 0; }
+
+ wasm_download_data(name_ptr, name_len, data_ptr, data_len) {
+ const name = this.stringFromPointer(name_ptr, name_len);
+ const data = this.bytesFromPointer(data_ptr, data_len);
+ const blob = new Blob([data], { type: "application/octet-stream" });
+ const fileURL = URL.createObjectURL(blob);
+ const dl = document.createElement("a");
+ dl.href = fileURL;
+ dl.download = name;
+ dl.click();
+ dl.remove();
+ URL.revokeObjectURL(fileURL);
+ }
+
+ wasm_open_file_picker(_id, _accept_ptr, _accept_len, _multiple) { }
+
+ wasm_get_file_size(_id, _file_index) { return -1; }
+
+ wasm_get_file_name(_id, _file_index) { return 0; }
+
+ wasm_read_file_data(_id, _file_index, _data) { }
+
+ wasm_get_number_of_files_available(_id) { return 0; }
+
+ wasm_clipboardTextSet(_ptr, _len) { }
+
+ wasm_add_noto_font() { }
+
+ wasm_canvas_info(_out_pw, _out_ph, _out_cw, _out_ch) { }
+
+ wasm_wait_event(_timeout_ms) { return 0; }
+
+ wasm_send_offscreencanvas_bitmap() { }
+}
diff --git a/src/backends/web-standalone.js b/src/backends/web-standalone.js
new file mode 100644
index 000000000..66a37a24d
--- /dev/null
+++ b/src/backends/web-standalone.js
@@ -0,0 +1,333 @@
+/// @file web-standalone.js
+/// Main-thread script for dvui standalone/Worker mode.
+/// Sets up DOM event listeners and forwards events to the Worker
+/// via SharedArrayBuffer + Atomics.notify().
+/// Requires SharedArrayBuffer/Atomics and crossOriginIsolated.
+
+import {
+ TOTAL_SHARED_SIZE,
+ SIGNAL_INDEX,
+ WRITE_CURSOR_INDEX,
+ READ_CURSOR_INDEX,
+ COLOR_SCHEME_INDEX,
+ CANVAS_INFO_OFFSET,
+ EVENT_RING_OFFSET,
+ EVENT_SIZE,
+ MAX_EVENTS,
+ STRING_AREA_OFFSET,
+ STRING_AREA_SIZE,
+ encodeModifiers,
+ touchIndex,
+ WheelHandler,
+ HiddenInputManager,
+ utf8encoder,
+ getTouchCoords,
+} from "./web-common.js";
+
+/**
+ * @param {string | HTMLCanvasElement} canvasArg
+ * @param {string} wasmUrl
+ * @param {string} [workerUrl]
+ * @returns {Promise<{worker: Worker, sharedBuffer: SharedArrayBuffer}>}
+ */
+export function dvuiStandalone(canvasArg, wasmUrl, workerUrl = "web-worker.js") {
+ /** @type {HTMLCanvasElement} */
+ const canvas = canvasArg instanceof HTMLCanvasElement
+ ? canvasArg
+ : document.querySelector(canvasArg);
+
+ if (!canvas) {
+ throw new Error("Could not find canvas element: " + canvasArg);
+ }
+
+ const search = new URLSearchParams(window.location.search);
+ const debugParam = search.get("dvui_debug");
+ const debugEnabled = debugParam === "1";
+ if (debugEnabled) {
+ console.info("[dvui-standalone] script loaded", {
+ debugEnabled,
+ href: window.location.href,
+ });
+ }
+ if (typeof SharedArrayBuffer === "undefined") {
+ console.error("SharedArrayBuffer is not available");
+ }
+ if (typeof Atomics === "undefined") {
+ console.error("Atomics is not available");
+ }
+ if (!window.crossOriginIsolated) {
+ console.error("crossOriginIsolated is false (missing COOP/COEP)");
+ }
+
+ // Create shared memory
+ const sharedBuffer = new SharedArrayBuffer(TOTAL_SHARED_SIZE);
+ const signalArray = new Int32Array(sharedBuffer, 0, 4);
+ const canvasInfoFloat = new Float32Array(sharedBuffer, CANVAS_INFO_OFFSET, 4);
+
+ // String write cursor within the string area (wraps around when full)
+ let stringWriteOffset = 0;
+
+ const worker = new Worker(workerUrl, { type: "module" });
+ worker.onerror = function(event) {
+ console.error("Worker error:", event);
+ };
+
+ if (debugEnabled) {
+ console.debug("[dvui-standalone] debug enabled");
+ }
+
+ // Detect color scheme and write to shared buffer
+ function updateColorScheme() {
+ if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
+ Atomics.store(signalArray, COLOR_SCHEME_INDEX, 1);
+ } else if (window.matchMedia("(prefers-color-scheme: light)").matches) {
+ Atomics.store(signalArray, COLOR_SCHEME_INDEX, 2);
+ } else {
+ Atomics.store(signalArray, COLOR_SCHEME_INDEX, 0);
+ }
+ }
+ updateColorScheme();
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", updateColorScheme);
+
+ // Update canvas dimensions in shared buffer
+ function updateCanvasInfo() {
+ const scale = window.devicePixelRatio;
+ const w = canvas.clientWidth;
+ const h = canvas.clientHeight;
+ canvasInfoFloat[0] = Math.round(w * scale); // pixel width
+ canvasInfoFloat[1] = Math.round(h * scale); // pixel height
+ canvasInfoFloat[2] = w; // canvas (CSS) width
+ canvasInfoFloat[3] = h; // canvas (CSS) height
+ }
+ updateCanvasInfo();
+
+ /** Write an event into the ring buffer and wake the Worker */
+ function pushEvent(kind, int1, int2, float1, float2) {
+ const writeCursor = Atomics.load(signalArray, WRITE_CURSOR_INDEX);
+ const readCursor = Atomics.load(signalArray, READ_CURSOR_INDEX);
+
+ // Check if ring is full using bitwise OR to coerce subtraction into 32-bit signed space to address overflows.
+ if (((writeCursor - readCursor) | 0) >= MAX_EVENTS) {
+ console.warn("dvui event ring full, dropping event");
+ return;
+ }
+
+ // Use a bitwise AND to force 32-bit wrap when cursor position integer overflows.
+ const offset = EVENT_RING_OFFSET + (writeCursor & (MAX_EVENTS - 1)) * EVENT_SIZE;
+ const view = new DataView(sharedBuffer, offset, EVENT_SIZE);
+ view.setUint8(0, kind);
+ view.setUint32(4, int1, true);
+ view.setUint32(8, int2, true);
+ view.setFloat32(12, float1, true);
+ view.setFloat32(16, float2, true);
+
+ Atomics.store(signalArray, WRITE_CURSOR_INDEX, writeCursor + 1);
+
+ // Wake the worker
+ Atomics.store(signalArray, SIGNAL_INDEX, 1);
+ Atomics.notify(signalArray, SIGNAL_INDEX);
+ }
+
+ /** Write a string into the string area and return its offset */
+ function pushString(str) {
+ const bytes = utf8encoder.encode(str);
+ if (stringWriteOffset + bytes.length > STRING_AREA_SIZE) {
+ stringWriteOffset = 0; // wrap around (best effort)
+ }
+ const dest = new Uint8Array(sharedBuffer, STRING_AREA_OFFSET + stringWriteOffset, bytes.length);
+ dest.set(bytes);
+ const offset = stringWriteOffset;
+ stringWriteOffset += bytes.length;
+ return [offset, bytes.length];
+ }
+
+ /** Push a key event with string data */
+ function pushKeyEvent(kind, key, repeat, modifiers) {
+ const [strOffset, strLen] = pushString(key);
+ pushEvent(kind, strOffset, strLen, repeat ? 1 : 0, modifiers);
+ }
+
+ // Touch tracking
+ const touches = [];
+
+ const wheelHandler = new WheelHandler();
+ const hiddenInputMgr = new HiddenInputManager(canvas);
+
+ // ---- Event listeners ----
+
+ canvas.addEventListener("contextmenu", ev => ev.preventDefault());
+
+ window.addEventListener("resize", () => {
+ updateCanvasInfo();
+ // Wake the worker so it sees the new size
+ Atomics.store(signalArray, SIGNAL_INDEX, 1);
+ Atomics.notify(signalArray, SIGNAL_INDEX);
+ });
+
+ const resizeObserver = new ResizeObserver(() => {
+ updateCanvasInfo();
+ Atomics.store(signalArray, SIGNAL_INDEX, 1);
+ Atomics.notify(signalArray, SIGNAL_INDEX);
+ });
+ resizeObserver.observe(canvas);
+
+ canvas.addEventListener("mousemove", ev => {
+ const rect = canvas.getBoundingClientRect();
+ const scale = window.devicePixelRatio;
+ const x = (ev.clientX - rect.left) * scale;
+ const y = (ev.clientY - rect.top) * scale;
+ pushEvent(1, 0, 0, x, y);
+ });
+
+ canvas.addEventListener("mousedown", ev => {
+ pushEvent(2, ev.button, 0, 0, 0);
+ });
+
+ canvas.addEventListener("mouseup", ev => {
+ pushEvent(3, ev.button, 0, 0, 0);
+ });
+
+ canvas.addEventListener("wheel", ev => {
+ ev.preventDefault();
+ for (const action of wheelHandler.processWheelEvent(ev)) {
+ pushEvent(4, action.axis, action.trackpad, action.ticks, 0);
+ }
+ }, { passive: false });
+
+ const keydown = ev => {
+ if (ev.key === "Tab") {
+ if (ev.ctrlKey) return;
+ ev.preventDefault();
+ }
+ if (ev.key.length > 0) {
+ pushKeyEvent(5, ev.key, ev.repeat, encodeModifiers(ev));
+ }
+ };
+ canvas.addEventListener("keydown", keydown);
+ hiddenInputMgr.hiddenInput.addEventListener("keydown", keydown);
+
+ const keyup = ev => {
+ pushKeyEvent(6, ev.key, false, encodeModifiers(ev));
+ };
+ canvas.addEventListener("keyup", keyup);
+ hiddenInputMgr.hiddenInput.addEventListener("keyup", keyup);
+
+ hiddenInputMgr.hiddenInput.addEventListener("beforeinput", ev => {
+ ev.preventDefault();
+ if (ev.data && !ev.isComposing) {
+ const [strOffset, strLen] = pushString(ev.data);
+ pushEvent(7, strOffset, strLen, 0, 0);
+ }
+ });
+ hiddenInputMgr.hiddenInput.addEventListener("compositionend", ev => {
+ if (ev.data) {
+ const [strOffset, strLen] = pushString(ev.data);
+ pushEvent(7, strOffset, strLen, 0, 0);
+ }
+ ev.target.value = "";
+ });
+
+ canvas.addEventListener("touchstart", ev => {
+ ev.preventDefault();
+ const rect = canvas.getBoundingClientRect();
+ for (let i = 0; i < ev.changedTouches.length; i++) {
+ const touch = ev.changedTouches[i];
+ const [x, y] = getTouchCoords(touch, rect);
+ const tidx = touchIndex(touches, touch.identifier);
+ pushEvent(8, touches[tidx][1], 0, x, y);
+ }
+ });
+ canvas.addEventListener("touchend", ev => {
+ ev.preventDefault();
+ const rect = canvas.getBoundingClientRect();
+ for (let i = 0; i < ev.changedTouches.length; i++) {
+ const touch = ev.changedTouches[i];
+ const [x, y] = getTouchCoords(touch, rect);
+ const tidx = touchIndex(touches, touch.identifier);
+ pushEvent(9, touches[tidx][1], 0, x, y);
+ touches.splice(tidx, 1);
+ }
+ hiddenInputMgr.check();
+ });
+ canvas.addEventListener("touchmove", ev => {
+ ev.preventDefault();
+ const rect = canvas.getBoundingClientRect();
+ for (let i = 0; i < ev.changedTouches.length; i++) {
+ const touch = ev.changedTouches[i];
+ const [x, y] = getTouchCoords(touch, rect);
+ const tidx = touchIndex(touches, touch.identifier);
+ pushEvent(10, touches[tidx][1], 0, x, y);
+ }
+ });
+
+ // Handle messages from Worker
+ worker.onmessage = function (e) {
+ const msg = e.data;
+ switch (msg.type) {
+ case "bitmap": {
+ // Doesn't copy the entire image for performance: https://html.spec.whatwg.org/multipage/canvas.html#the-imagebitmaprenderingcontext-interface
+ canvas.getContext("bitmaprenderer").transferFromImageBitmap(msg.bitmap);
+ break;
+ }
+ case "cursor":
+ canvas.style.cursor = msg.cursor;
+ break;
+ case "text_input":
+ hiddenInputMgr.setRect(msg.rect);
+ hiddenInputMgr.check();
+ break;
+ case "open_url":
+ if (msg.new_window) {
+ window.open(msg.url);
+ } else {
+ window.location.href = msg.url;
+ }
+ break;
+ case "download": {
+ const blob = new Blob([msg.data], { type: "application/octet-stream" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = msg.name;
+ a.click();
+ a.remove();
+ setTimeout(() => {
+ URL.revokeObjectURL(url);
+ }, 40000);
+ break;
+ }
+ case "clipboard_set":
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(msg.text);
+ }
+ break;
+ case "panic":
+ console.error("WASM Panic:", msg.message);
+ alert(msg.message);
+ break;
+ case "error":
+ console.error("Worker error:", msg.message);
+ break;
+ case "ready":
+ console.log("dvui worker ready");
+ break;
+ case "debug":
+ console.log("[dvui-worker]", msg.message, msg.data ?? "");
+ break;
+ }
+ };
+
+ // Send init message to worker with OffscreenCanvas
+ worker.postMessage({
+ type: "init",
+ sharedBuffer: sharedBuffer,
+ wasmUrl: wasmUrl,
+ platform: navigator.platform || "",
+ debug: debugEnabled,
+ });
+ if (debugEnabled) {
+ console.info("[dvui-standalone] init posted to worker", { debugEnabled, wasmUrl, workerUrl });
+ }
+
+ return Promise.resolve({ worker, sharedBuffer });
+}
diff --git a/src/backends/web-worker.js b/src/backends/web-worker.js
new file mode 100644
index 000000000..77476a79a
--- /dev/null
+++ b/src/backends/web-worker.js
@@ -0,0 +1,404 @@
+/// @file web-worker.js
+/// Worker-side script for dvui standalone mode.
+/// Runs WASM in a Web Worker with OffscreenCanvas for WebGL rendering.
+/// Events arrive via SharedArrayBuffer from the main thread.
+///
+
+import {
+ WebRenderer,
+ SIGNAL_INDEX,
+ WRITE_CURSOR_INDEX,
+ READ_CURSOR_INDEX,
+ COLOR_SCHEME_INDEX,
+ CANVAS_INFO_OFFSET,
+ EVENT_RING_OFFSET,
+ EVENT_SIZE,
+ MAX_EVENTS,
+ STRING_AREA_OFFSET,
+ STRING_AREA_SIZE,
+ dvui_fetch,
+} from "./web-common.js";
+
+class WorkerRenderer extends WebRenderer {
+ /** @type {OffscreenCanvas | null} */
+ canvas = null;
+ /** @type {SharedArrayBuffer | null} */
+ sharedBuffer = null;
+ /** @type {Int32Array | null} */
+ signalArray = null;
+ /** @type {Float32Array | null} */
+ canvasInfoFloat = null;
+
+ cachedPixelWidth = 800;
+ cachedPixelHeight = 600;
+ cachedCanvasWidth = 800;
+ cachedCanvasHeight = 600;
+
+ debugEnabled = false;
+
+ useJspi = false;
+
+ _yieldResolve = null;
+
+ constructor() {
+ super();
+ this.imports = { dvui: this.buildImports() };
+
+ // JSPI lets the blocking imports (wasm_wait_event, wasm_sleep) suspend
+ // the wasm stack instead of blocking the worker thread, so the worker
+ // event loop keeps running even though main() never returns. Without
+ // it the event loop is starved for the entire lifetime of main(), and
+ // the browser never gets to run its worker-side bookkeeping for
+ // transferred ImageBitmaps, which eventually crashes the renderer
+ // (STATUS_ACCESS_VIOLATION in msedge.dll after ~10-14k transfers,
+ // reproduced on Edge 149 and 151).
+ this.useJspi = typeof WebAssembly.Suspending === "function" &&
+ typeof WebAssembly.promising === "function" &&
+ typeof Atomics.waitAsync === "function";
+ if (this.useJspi) {
+ this.imports.dvui.wasm_wait_event = new WebAssembly.Suspending(this.wasmWaitEventJspi.bind(this));
+ this.imports.dvui.wasm_sleep = new WebAssembly.Suspending(this.wasmSleepJspi.bind(this));
+ }
+
+ // Reused channel for cheap macrotask yields (setTimeout(0) clamps to
+ // ~4ms when nested; a MessageChannel roundtrip does not).
+ this._yieldChannel = new MessageChannel();
+ this._yieldChannel.port1.onmessage = () => {
+ const r = this._yieldResolve;
+ this._yieldResolve = null;
+ if (r) r();
+ };
+ }
+
+ /** Resolve on the next macrotask, letting the worker event loop run. */
+ macroYield() {
+ return new Promise((resolve) => {
+ this._yieldResolve = resolve;
+ this._yieldChannel.port2.postMessage(0);
+ });
+ }
+
+ /** Async equivalent of Atomics.wait(signalArray, index, expected, timeout). */
+ waitIndexAsync(index, expected, timeout) {
+ const res = Atomics.waitAsync(this.signalArray, index, expected, timeout);
+ return res.async ? res.value : Promise.resolve(res.value);
+ }
+
+ async wasmSleepJspi(ms) {
+ // Mirror Atomics.wait(signalArray, SIGNAL_INDEX, 0, ms): only actually
+ // wait while the value is still 0.
+ if (Atomics.load(this.signalArray, SIGNAL_INDEX) !== 0) return;
+ await this.waitIndexAsync(SIGNAL_INDEX, 0, ms);
+ }
+
+ async wasmWaitEventJspi(timeout_ms) {
+ const drainedBefore = this.drainEvents();
+
+ if (timeout_ms === 0) {
+ // Not waiting for events, but still yield a macrotask every frame
+ // so the worker event loop can run (see useJspi comment above).
+ await this.macroYield();
+ } else {
+ // Only sleep if no events snuck in between the drain above and
+ // resetting the signal flag here.
+ Atomics.store(this.signalArray, SIGNAL_INDEX, 0);
+ if (Atomics.load(this.signalArray, WRITE_CURSOR_INDEX) === Atomics.load(this.signalArray, READ_CURSOR_INDEX)) {
+ await this.waitIndexAsync(SIGNAL_INDEX, 0, timeout_ms < 0 ? undefined : timeout_ms);
+ }
+ }
+
+ // Refresh after waking, not before sleeping: the main thread writes
+ // new canvas info to the shared buffer before waking us on resize, and
+ // the frame about to render must see it (otherwise it renders one
+ // stretched frame at the old size).
+ this.updateCanvasInfo();
+ this.syncCanvasSize(this.cachedPixelWidth, this.cachedPixelHeight);
+
+ const drainedAfter = this.drainEvents();
+ return (drainedBefore + drainedAfter) > 0 ? 1 : 0;
+ }
+
+ debugLog(message, data = null) {
+ if (!this.debugEnabled) return;
+ if (data === null) {
+ self.postMessage({ type: "debug", message });
+ } else {
+ self.postMessage({ type: "debug", message, data });
+ }
+ }
+
+ syncCanvasSize(pixelWidth, pixelHeight) {
+ if (!(pixelWidth > 0 && pixelHeight > 0)) return false;
+ if (this.gl.canvas.width === pixelWidth && this.gl.canvas.height === pixelHeight) return false;
+
+ this.gl.canvas.width = pixelWidth;
+ this.gl.canvas.height = pixelHeight;
+ this.renderTargetSize = [pixelWidth, pixelHeight];
+ this.gl.viewport(0, 0, pixelWidth, pixelHeight);
+ this.gl.scissor(0, 0, pixelWidth, pixelHeight);
+
+ this.gl.enable(this.gl.BLEND);
+ this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA);
+ this.gl.enable(this.gl.SCISSOR_TEST);
+ return true;
+ }
+
+ updateCanvasInfo() {
+ this.cachedPixelWidth = this.canvasInfoFloat[0];
+ this.cachedPixelHeight = this.canvasInfoFloat[1];
+ this.cachedCanvasWidth = this.canvasInfoFloat[2];
+ this.cachedCanvasHeight = this.canvasInfoFloat[3];
+ }
+
+ drainEvents() {
+ const writeCursor = Atomics.load(this.signalArray, WRITE_CURSOR_INDEX);
+ let readCursor = Atomics.load(this.signalArray, READ_CURSOR_INDEX);
+ let drained = 0;
+
+ while (readCursor !== writeCursor) {
+ const offset = EVENT_RING_OFFSET + (readCursor & (MAX_EVENTS - 1)) * EVENT_SIZE;
+ const view = new DataView(this.sharedBuffer, offset, EVENT_SIZE);
+ const kind = view.getUint8(0);
+ const int1 = view.getUint32(4, true);
+ const int2 = view.getUint32(8, true);
+ const float1 = view.getFloat32(12, true);
+ const float2 = view.getFloat32(16, true);
+
+ if (kind === 5 || kind === 6 || kind === 7) {
+ // Key/text strings live in the shared string area; int1 = offset, int2 = length.
+ const strOffset = int1;
+ const strLen = int2;
+ if (strLen > 0 && strLen < 256) {
+ const strBytes = new Uint8Array(this.sharedBuffer, STRING_AREA_OFFSET + strOffset, strLen);
+ const ptr = this.allocBuffer(this.instance.exports.arena_u8, strBytes);
+ this.instance.exports.add_event(kind, ptr, strLen, float1, float2);
+ }
+ } else {
+ this.instance.exports.add_event(kind, int1, int2, float1, float2);
+ }
+
+ readCursor++;
+ Atomics.store(this.signalArray, READ_CURSOR_INDEX, readCursor);
+ drained++;
+ }
+
+ return drained;
+ }
+
+ setupWebGL(canvas) {
+ const program = super.setupWebGL(canvas);
+ if (this.gl) {
+ this.debugLog("setupWebGL", {
+ context: this.webgl2 ? "webgl2" : "webgl1",
+ version: this.gl.getParameter(this.gl.VERSION),
+ renderer: this.gl.getParameter(this.gl.RENDERER),
+ hasCommit: typeof this.gl.commit === "function",
+ });
+ }
+ return program;
+ }
+
+ async init(msg) {
+ this.debugEnabled = !!msg.debug;
+ this.debugLog("worker init received", {
+ debugEnabled: this.debugEnabled,
+ wasmUrlType: typeof msg.wasmUrl,
+ hasSharedBuffer: !!msg.sharedBuffer,
+ });
+
+ this.sharedBuffer = msg.sharedBuffer;
+ this.signalArray = new Int32Array(this.sharedBuffer);
+ this.canvasInfoFloat = new Float32Array(this.sharedBuffer, CANVAS_INFO_OFFSET, 4);
+
+ this.canvas = new OffscreenCanvas(this.canvasInfoFloat[0], this.canvasInfoFloat[1]);
+ this.setupWebGL(this.canvas);
+
+ if (!this.gl) {
+ self.postMessage({ type: "error", message: "Failed to initialize WebGL in worker" });
+ return;
+ }
+
+ let result;
+ if (typeof msg.wasmUrl === "string") {
+ const response = await fetch(msg.wasmUrl);
+ result = await WebAssembly.instantiateStreaming(response, this.imports);
+ } else {
+ result = await WebAssembly.instantiate(msg.wasmUrl, this.imports);
+ }
+
+ this.setInstance(result.instance);
+ this.debugLog("wasm loaded", { has_main: !!this.instance.exports.main });
+
+ this.updateCanvasInfo();
+ const w = this.cachedPixelWidth || 800;
+ const h = this.cachedPixelHeight || 600;
+ this.gl.canvas.width = w;
+ this.gl.canvas.height = h;
+ this.renderTargetSize = [w, h];
+ this.gl.viewport(0, 0, w, h);
+ this.gl.scissor(0, 0, w, h);
+
+ self.postMessage({ type: "ready" });
+ this.debugLog("worker ready: wasm loaded", { has_main: !!this.instance.exports.main });
+
+ if (!this.instance.exports.main) {
+ self.postMessage({ type: "error", message: "Web worker standalone mode requires an exported main() function!" });
+ return;
+ }
+
+ if (this.useJspi) {
+ // Run main() on a suspendible stack: when the app calls a blocking
+ // import it suspends instead of blocking the worker thread, so the
+ // event loop keeps running while main() never returns.
+ WebAssembly.promising(this.instance.exports.main)().catch((err) => {
+ console.error("Worker WASM error:", err);
+ self.postMessage({ type: "error", message: err?.stack || err?.toString?.() || "unknown worker error" });
+ });
+ } else {
+ this.instance.exports.main();
+ }
+ }
+
+ wasm_sleep(ms) {
+ Atomics.wait(this.signalArray, SIGNAL_INDEX, 0, ms);
+ }
+
+ wasm_refresh() {
+ // No-op in worker mode. The blocking loop drives frames.
+ }
+
+ wasm_pixel_width() {
+ this.updateCanvasInfo();
+ return this.cachedPixelWidth;
+ }
+
+ wasm_pixel_height() {
+ this.updateCanvasInfo();
+ return this.cachedPixelHeight;
+ }
+
+ wasm_canvas_width() {
+ this.updateCanvasInfo();
+ return this.cachedCanvasWidth;
+ }
+
+ wasm_canvas_height() {
+ this.updateCanvasInfo();
+ return this.cachedCanvasHeight;
+ }
+
+ wasm_canvas_info(out_pw, out_ph, out_cw, out_ch) {
+ this.updateCanvasInfo();
+ const mem = new Float32Array(this.instance.exports.memory.buffer);
+ mem[out_pw >> 2] = this.cachedPixelWidth;
+ mem[out_ph >> 2] = this.cachedPixelHeight;
+ mem[out_cw >> 2] = this.cachedCanvasWidth;
+ mem[out_ch >> 2] = this.cachedCanvasHeight;
+ }
+
+ wasm_wait_event(timeout_ms) {
+ const drainedBefore = this.drainEvents();
+
+ if (timeout_ms !== 0) {
+ // Only sleep if no events snuck in between the drain above and
+ // resetting the signal flag here.
+ Atomics.store(this.signalArray, SIGNAL_INDEX, 0);
+ if (Atomics.load(this.signalArray, WRITE_CURSOR_INDEX) === Atomics.load(this.signalArray, READ_CURSOR_INDEX)) {
+ if (timeout_ms < 0) {
+ Atomics.wait(this.signalArray, SIGNAL_INDEX, 0);
+ } else {
+ Atomics.wait(this.signalArray, SIGNAL_INDEX, 0, timeout_ms);
+ }
+ }
+ }
+
+ // Refresh after waking, not before sleeping: the main thread writes
+ // new canvas info to the shared buffer before waking us on resize, and
+ // the frame about to render must see it (otherwise it renders one
+ // stretched frame at the old size).
+ this.updateCanvasInfo();
+ this.syncCanvasSize(this.cachedPixelWidth, this.cachedPixelHeight);
+
+ const drainedAfter = this.drainEvents();
+ return (drainedBefore + drainedAfter) > 0 ? 1 : 0;
+ }
+
+ wasm_preferred_color_scheme() {
+ return Atomics.load(this.signalArray, COLOR_SCHEME_INDEX);
+ }
+
+ wasm_prefers_reduced_motion() {
+ return 0; // no preference in worker mode (not forwarded via shared buffer yet)
+ }
+
+ wasm_renderTarget(id) {
+ super.wasm_renderTarget(id);
+ if (this.debugEnabled) {
+ this.debugLog("renderTarget", {
+ id,
+ using_fb: this.using_fb,
+ renderTargetSize: this.renderTargetSize,
+ drawingBuffer: [this.gl.drawingBufferWidth, this.gl.drawingBufferHeight],
+ canvasSize: [this.gl.canvas.width, this.gl.canvas.height],
+ });
+ }
+ }
+
+ wasm_send_offscreencanvas_bitmap() {
+ // Doesn't copy the entire image for performance: https://html.spec.whatwg.org/multipage/canvas.html#the-offscreencanvas-interface
+ const bitmap = this.canvas.transferToImageBitmap();
+ self.postMessage({ type: "bitmap", bitmap: bitmap }, [bitmap]);
+ }
+
+ wasm_cursor(name_ptr, name_len) {
+ const cursor_name = this.stringFromPointer(name_ptr, name_len);
+ self.postMessage({ type: "cursor", cursor: cursor_name });
+ }
+
+ wasm_text_input(x, y, w, h) {
+ self.postMessage({ type: "text_input", rect: [x, y, w, h] });
+ }
+
+ wasm_open_url(ptr, len, new_win) {
+ const url = this.stringFromPointer(ptr, len);
+ self.postMessage({ type: "open_url", url, new_window: !!new_win });
+ }
+
+ wasm_download_data(name_ptr, name_len, data_ptr, data_len) {
+ const name = this.stringFromPointer(name_ptr, name_len);
+ const data = new Uint8Array(this.bytesFromPointer(data_ptr, data_len));
+ self.postMessage({ type: "download", name, data }, [data.buffer]);
+ }
+
+ wasm_clipboardTextSet(ptr, len) {
+ if (len === 0) return;
+ const text = this.stringFromPointer(ptr, len);
+ self.postMessage({ type: "clipboard_set", text });
+ }
+
+ wasm_add_noto_font() {
+ dvui_fetch("NotoSansKR-Regular.ttf").then((bytes) => {
+ const ptr = this.allocBuffer(this.instance.exports.gpa_u8, bytes);
+ this.instance.exports.new_font(ptr, bytes.length);
+ });
+ }
+
+ wasm_panic(ptr, len) {
+ const msg = this.stringFromPointer(ptr, len);
+ console.error("WASM PANIC:", msg);
+ self.postMessage({ type: "panic", message: msg });
+ }
+}
+
+self.onmessage = async function (e) {
+ const msg = e.data;
+ if (msg.type === "init") {
+ const renderer = new WorkerRenderer();
+ try {
+ await renderer.init(msg);
+ } catch (err) {
+ console.error("Worker WASM error:", err);
+ self.postMessage({ type: "error", message: err?.stack || err?.toString?.() || "unknown worker error" });
+ }
+ }
+};
diff --git a/src/backends/web.js b/src/backends/web.js
index 808341f05..5fab8250d 100644
--- a/src/backends/web.js
+++ b/src/backends/web.js
@@ -2,16 +2,7 @@
/**@typedef {BigInt} Id */
-/**
- * @param {string} url
- * @returns {Promise}
- */
-async function dvui_fetch(url) {
- let x = await fetch(url);
- let blob = await x.blob();
- //console.log("dvui_fetch: " + blob.size);
- return new Uint8Array(await blob.arrayBuffer());
-}
+import { WebRenderer, encodeModifiers, touchIndex, WheelHandler, HiddenInputManager, utf8encoder, getTouchCoords, dvui_fetch } from "./web-common.js";
/**
* @param {string} accept Maps to the accept attribute of the file input element
@@ -46,86 +37,6 @@ async function dvui_open_file_picker(accept, multiple) {
});
}
-const vertexShaderSource_webgl = `
- precision mediump float;
-
- attribute vec4 aVertexPosition;
- attribute vec4 aVertexColor;
- attribute vec2 aTextureCoord;
-
- uniform mat4 uMatrix;
-
- varying vec4 vColor;
- varying vec2 vTextureCoord;
-
- void main() {
- gl_Position = uMatrix * aVertexPosition;
- vColor = aVertexColor / 255.0; // normalize u8 colors to 0-1
- vTextureCoord = aTextureCoord;
- }
-`;
-
-const vertexShaderSource_webgl2 = `# version 300 es
-
- precision mediump float;
-
- in vec4 aVertexPosition;
- in vec4 aVertexColor;
- in vec2 aTextureCoord;
-
- uniform mat4 uMatrix;
-
- out vec4 vColor;
- out vec2 vTextureCoord;
-
- void main() {
- gl_Position = uMatrix * aVertexPosition;
- vColor = aVertexColor / 255.0; // normalize u8 colors to 0-1
- vTextureCoord = aTextureCoord;
- }
-`;
-
-const fragmentShaderSource_webgl = `
- precision mediump float;
-
- varying vec4 vColor;
- varying vec2 vTextureCoord;
-
- uniform sampler2D uSampler;
- uniform bool useTex;
-
- void main() {
- if (useTex) {
- gl_FragColor = texture2D(uSampler, vTextureCoord) * vColor;
- }
- else {
- gl_FragColor = vColor;
- }
- }
-`;
-
-const fragmentShaderSource_webgl2 = `# version 300 es
-
- precision mediump float;
-
- in vec4 vColor;
- in vec2 vTextureCoord;
-
- uniform sampler2D uSampler;
- uniform bool useTex;
-
- out vec4 fragColor;
-
- void main() {
- if (useTex) {
- fragColor = texture(uSampler, vTextureCoord) * vColor;
- }
- else {
- fragColor = vColor;
- }
- }
-`;
-
/**
* @param {string | HTMLCanvasElement} canvas - A canvas element or string id of one
* @param {DVUI.WasmArg} wasmRef - The url to the wasm file, to be used in `fetch`
@@ -144,850 +55,30 @@ export function dvui(canvas, wasmRef) {
});
}
-const utf8decoder = new TextDecoder();
-const utf8encoder = new TextEncoder();
-
-export class Dvui {
- /** @type {WebGL2RenderingContext | WebGLRenderingContext} */
- gl;
- /** @type {WebGLBuffer} */
- indexBuffer;
- /** @type {WebGLBuffer} */
- vertexBuffer;
- /** @type {WebGLProgram} */
- shaderProgram;
- /** @type {{ attribLocations: { vertexPosition: number;
- vertexColor: number;
- textureCoord: number;
- };
- uniformLocations: {
- matrix: WebGLUniformLocation | null;
- uSampler: WebGLUniformLocation | null;
- useTex: WebGLUniformLocation | null;
- };
- }} */
- programInfo;
- /** @type {Map} */
- textures = new Map();
- newTextureId = 1;
-
- /** @returns {[WebGLTexture, number, number] | null} */
- textureEntry(id) {
- if (id === 0) return null;
- return this.textures.get(id) ?? null;
- }
- using_fb = false;
- /** @type {WebGLFramebuffer | null} */
- frame_buffer = null;
- /** @type {[number, number]} */
- renderTargetSize = [0, 0];
-
+export class Dvui extends WebRenderer {
renderRequested = false;
renderTimeoutId = 0;
/** @type {WebAssembly.ModuleImports} */
imports;
- /** @type {WebAssembly.Instance} */
- instance;
stopped = false;
- console_string = "";
- /** @type {HTMLInputElement} */
- hidden_input;
+ /** @type {HiddenInputManager | null} */
+ hiddenInputMgr = null;
/**
* list of tuple (touch identifier, initial index)
* @type {[number, number][]} */
touches = [];
- /** The lowest deltaX/Y seen, used to determine the delta for touchpads
- *
- * The first number is x and second is y
- * @type {[number, number]} */
- scroll_lowest = [99999, 99999];
- /** The lowest deltaX/Y seen in this batch (resets if none in 1s). Used to
- * determine if we think a touchpad is being used and also as the delta for
- * mouse wheels.
- *
- * The first number is x and second is y
- * @type {[number, number]} */
- scroll_lowest_batch = [99999, 99999];
- scroll_last_ms = Date.now();
- /**
- * x y w h of on screen keyboard editing position, or empty if none
- *
- * @type {[number, number, number, number] | []} */
- textInputRect = [];
+ wheelHandler = new WheelHandler();
// Used for file uploads. Only valid for one frame
filesCacheModified = false;
/** @type {Map} */
filesCache = new Map();
- //let par = document.createElement("p");
- //document.body.prepend(par);
-
- get webgl2() {
- return this.gl instanceof WebGL2RenderingContext;
- }
-
- // This does 2 things:
- // * on desktop it's needed for us to get text events (not just char down/up)
- // * on touch it's needed to show the on screen keyboard
- oskCheck() {
- if (this.textInputRect.length == 0) {
- this.gl.canvas.focus();
- } else {
- const rect = this.gl.canvas.getBoundingClientRect();
- const left = window.scrollX + rect.left + this.textInputRect[0];
- const top = window.scrollY + rect.top + this.textInputRect[1];
- // limit the width and height to prevent overflow
- const width = Math.max(
- 0,
- Math.min(
- this.textInputRect[2],
- this.gl.canvas.clientWidth - left,
- ),
- );
- const height = Math.max(
- 0,
- Math.min(
- this.textInputRect[2],
- this.gl.canvas.clientHeight - top,
- ),
- );
- this.hidden_input.style.left = left + "px";
- this.hidden_input.style.top = top + "px";
- this.hidden_input.style.width = width + "px";
- this.hidden_input.style.height = height + "px";
- this.hidden_input.focus();
- //par.textContent = hidden_input.style.left + " " + hidden_input.style.top + " " + hidden_input.style.width + " " + hidden_input.style.height;
- }
- }
-
- touchIndex(pointerId) {
- let idx = this.touches.findIndex((e) => e[0] === pointerId);
- if (idx < 0) {
- idx = this.touches.length;
- this.touches.push([pointerId, idx]);
- }
-
- return idx;
- }
-
- /**
- * @param {DVUI.AllocatorFunction} allocFn
- * @param {number} len
- * @returns {[pointer: number, slice: Uint8Array]}
- */
- genericAlloc(allocFn, len) {
- const pointer = allocFn(len);
- const slice = new Uint8Array(
- this.instance.exports.memory.buffer,
- pointer,
- len
- );
-
- return [pointer, slice];
- }
-
- /**
- * @param {DVUI.AllocatorFunction} allocFn
- * @param {ArrayLike} bytes
- * @returns {number} pointer
- */
- allocBuffer(allocFn, bytes) {
- const [pointer, slice] = this.genericAlloc(allocFn, bytes.length);
- slice.set(bytes);
- return pointer;
- }
-
- /**
- * @param {DVUI.AllocatorFunction} allocFn
- * @param {ArrayLike} bytes
- * @param {number} sentinel
- * @returns {number} pointer
- */
- allocBufferZ(allocFn, bytes, sentinel = 0) {
- const [pointer, slice] = this.genericAlloc(allocFn, bytes.length + 1);
- slice.set(bytes);
- slice[bytes.length] = sentinel;
- return pointer;
- }
-
- /**
- * @param {DVUI.AllocatorFunction} allocFn
- * @param {string} string
- * @returns
- */
- allocString(allocFn, string) {
- const buffer = utf8encoder.encode(string);
- return this.allocBuffer(allocFn, buffer);
- };
-
- /**
- * @param {DVUI.AllocatorFunction} allocFn
- * @param {string} string
- * @param {number} sentinel
- * @returns {number} pointer
- */
- allocStringZ(allocFn, string, sentinel) {
- const buffer = utf8encoder.encode(string);
- return this.allocBufferZ(allocFn, buffer, sentinel);
- };
-
- /**
- * @param {number} ptr
- * @param {number} length
- * @returns {string}
- */
- stringFromPointer(ptr, length) {
- return utf8decoder.decode(this.bytesFromPointer(ptr, length));
- }
-
- /**
- * @param {number} ptr
- * @param {number} length
- * @returns {Uint8Array}
- */
- bytesFromPointer(ptr, length) {
- return new Uint8Array(this.instance.exports.memory.buffer, ptr, length)
- }
-
constructor() {
- this.hidden_input = document.createElement("input");
- this.hidden_input.setAttribute("autocapitalize", "none");
- this.hidden_input.style.position = "absolute";
- this.hidden_input.style.left = 0;
- this.hidden_input.style.top = 0;
- // remove extra size so input doesn't cause overflow
- this.hidden_input.style.padding = 0;
- this.hidden_input.style.border = 0;
- this.hidden_input.style.margin = 0;
- this.hidden_input.style.opacity = 0;
- this.hidden_input.style.zIndex = -1;
- document.body.prepend(this.hidden_input);
-
- this.imports = {
- wasm_about_webgl2: () => {
- if (this.webgl2) {
- return 1;
- } else {
- return 0;
- }
- },
- wasm_panic: (ptr, len) => {
- this.stop();
- const msg = this.stringFromPointer(ptr, len);
- console.error("PANIC:", msg);
- alert(msg);
- },
- wasm_console_drain: (ptr, len) => {
- this.console_string += this.stringFromPointer(ptr, len);
- },
- wasm_console_flush: (level) => {
- switch (level) {
- case 9:
- console.error(this.console_string);
- break;
- case 7:
- console.warn(this.console_string);
- break;
- case 5:
- console.info(this.console_string);
- break;
- case 3:
- console.debug(this.console_string);
- break;
- default:
- console.log(this.console_string);
- break;
- }
- this.console_string = "";
- },
- wasm_now: () => {
- return performance.now();
- },
- wasm_sleep: (ms) => {
- const end = Date.now() + ms;
- while (Date.now() < end) {
- // block because the point is to limit the framerate
- }
- },
- wasm_refresh: () => {
- this.requestRender();
- },
- wasm_pixel_width: () => {
- return this.gl.drawingBufferWidth;
- },
- wasm_pixel_height: () => {
- return this.gl.drawingBufferHeight;
- },
- wasm_frame_buffer: () => {
- if (this.using_fb) {
- return 1;
- } else {
- return 0;
- }
- },
- wasm_canvas_width: () => {
- return this.gl.canvas.clientWidth;
- },
- wasm_canvas_height: () => {
- return this.gl.canvas.clientHeight;
- },
- wasm_textureCreate: (pixels, width, height, interp, wrap_u, wrap_v) => {
- const pixelData = this.bytesFromPointer(pixels, width * height * 4);
-
- const texture = this.gl.createTexture();
- const id = this.newTextureId;
- //console.log("creating texture " + id);
- this.newTextureId += 1;
- this.textures.set(id, [texture, width, height]);
-
- this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
-
- this.gl.texImage2D(
- this.gl.TEXTURE_2D,
- 0,
- this.gl.RGBA,
- width,
- height,
- 0,
- this.gl.RGBA,
- this.gl.UNSIGNED_BYTE,
- pixelData,
- );
-
- if (this.webgl2) {
- this.gl.generateMipmap(this.gl.TEXTURE_2D);
- }
-
- if (interp == 0) {
- this.gl.texParameteri(
- this.gl.TEXTURE_2D,
- this.gl.TEXTURE_MIN_FILTER,
- this.gl.NEAREST,
- );
- this.gl.texParameteri(
- this.gl.TEXTURE_2D,
- this.gl.TEXTURE_MAG_FILTER,
- this.gl.NEAREST,
- );
- } else {
- this.gl.texParameteri(
- this.gl.TEXTURE_2D,
- this.gl.TEXTURE_MIN_FILTER,
- this.gl.LINEAR,
- );
- this.gl.texParameteri(
- this.gl.TEXTURE_2D,
- this.gl.TEXTURE_MAG_FILTER,
- this.gl.LINEAR,
- );
- }
- if (wrap_u === 1) {
- this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.REPEAT);
- } else {
- this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
- }
- if (wrap_v === 1) {
- this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.REPEAT);
- } else {
- this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
- }
-
- this.gl.bindTexture(this.gl.TEXTURE_2D, null);
-
- return id;
- },
- wasm_textureCreateTarget: (width, height, interp, wrap_u, wrap_v) => {
- const texture = this.gl.createTexture();
- const id = this.newTextureId;
- //console.log("creating texture " + id);
- this.newTextureId += 1;
- this.textures.set(id, [texture, width, height]);
-
- this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
-
- this.gl.texImage2D(
- this.gl.TEXTURE_2D,
- 0,
- this.gl.RGBA,
- width,
- height,
- 0,
- this.gl.RGBA,
- this.gl.UNSIGNED_BYTE,
- null,
- );
-
- if (interp == 0) {
- this.gl.texParameteri(
- this.gl.TEXTURE_2D,
- this.gl.TEXTURE_MIN_FILTER,
- this.gl.NEAREST,
- );
- this.gl.texParameteri(
- this.gl.TEXTURE_2D,
- this.gl.TEXTURE_MAG_FILTER,
- this.gl.NEAREST,
- );
- } else {
- this.gl.texParameteri(
- this.gl.TEXTURE_2D,
- this.gl.TEXTURE_MIN_FILTER,
- this.gl.LINEAR,
- );
- this.gl.texParameteri(
- this.gl.TEXTURE_2D,
- this.gl.TEXTURE_MAG_FILTER,
- this.gl.LINEAR,
- );
- }
- if (wrap_u === 1) {
- this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.REPEAT);
- } else {
- this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
- }
- if (wrap_v === 1) {
- this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.REPEAT);
- } else {
- this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
- }
-
- this.gl.bindTexture(this.gl.TEXTURE_2D, null);
-
- this.imports.wasm_textureClearTarget(id);
-
- return id;
- },
- wasm_textureClearTarget: (textureId) => {
- this.imports.wasm_renderTarget(textureId);
- this.gl.clearColor(0.0, 0.0, 0.0, 0.0); // fully transparent
- this.gl.clear(this.gl.COLOR_BUFFER_BIT);
- this.imports.wasm_renderTarget(0);
- },
- wasm_textureRead: (textureId, pixels_out, width, height) => {
- //console.log("textureRead " + textureId);
- const entry = this.textureEntry(textureId);
- if (entry === null) {
- console.warn(
- `wasm_textureRead: missing texture id ${textureId}`,
- );
- return;
- }
- const texture = entry[0];
-
- this.gl.bindFramebuffer(
- this.gl.FRAMEBUFFER,
- this.frame_buffer,
- );
- this.gl.framebufferTexture2D(
- this.gl.FRAMEBUFFER,
- this.gl.COLOR_ATTACHMENT0,
- this.gl.TEXTURE_2D,
- texture,
- 0,
- );
-
- var dest = this.bytesFromPointer(pixels_out, width * height * 4);
- this.gl.readPixels(
- 0,
- 0,
- width,
- height,
- this.gl.RGBA,
- this.gl.UNSIGNED_BYTE,
- dest,
- 0,
- );
-
- this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
- },
- wasm_renderTarget: (id) => {
- //console.log("renderTarget " + id);
- if (id === 0) {
- this.using_fb = false;
- this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
- this.renderTargetSize = [
- this.gl.drawingBufferWidth,
- this.gl.drawingBufferHeight,
- ];
- this.gl.viewport(
- 0,
- 0,
- this.renderTargetSize[0],
- this.renderTargetSize[1],
- );
- this.gl.scissor(
- 0,
- 0,
- this.renderTargetSize[0],
- this.renderTargetSize[1],
- );
- } else {
- this.using_fb = true;
- this.gl.bindFramebuffer(
- this.gl.FRAMEBUFFER,
- this.frame_buffer,
- );
-
- const rt = this.textureEntry(id);
- if (rt === null) {
- console.warn(
- `wasm_renderTarget: missing texture id ${id}`,
- );
- this.using_fb = false;
- this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
- this.renderTargetSize = [
- this.gl.drawingBufferWidth,
- this.gl.drawingBufferHeight,
- ];
- } else {
- this.gl.framebufferTexture2D(
- this.gl.FRAMEBUFFER,
- this.gl.COLOR_ATTACHMENT0,
- this.gl.TEXTURE_2D,
- rt[0],
- 0,
- );
- this.renderTargetSize = [rt[1], rt[2]];
- }
- this.gl.viewport(
- 0,
- 0,
- this.renderTargetSize[0],
- this.renderTargetSize[1],
- );
- this.gl.scissor(
- 0,
- 0,
- this.renderTargetSize[0],
- this.renderTargetSize[1],
- );
- }
- },
- wasm_textureDestroy: (id) => {
- //console.log("deleting texture " + id);
- const entry = this.textureEntry(id);
- if (entry === null) return;
- this.textures.delete(id);
- this.gl.deleteTexture(entry[0]);
- },
- wasm_renderGeometry: (
- textureId,
- index_ptr,
- index_len,
- vertex_ptr,
- vertex_len,
- sizeof_vertex,
- offset_pos,
- offset_col,
- offset_uv,
- clip,
- x,
- y,
- w,
- h,
- ) => {
- //console.log("drawClippedTriangles " + textureId + " sizeof " + sizeof_vertex + " pos " + offset_pos + " col " + offset_col + " uv " + offset_uv);
-
- //let old_scissor;
- if (clip === 1) {
- // just calling getParameter here is quite slow (5-10 ms per frame according to chrome)
- //old_scissor = gl.getParameter(gl.SCISSOR_BOX);
- this.gl.scissor(x, y, w, h);
- }
-
- this.gl.bindBuffer(
- this.gl.ELEMENT_ARRAY_BUFFER,
- this.indexBuffer,
- );
- const indices = new Uint16Array(
- this.instance.exports.memory.buffer,
- index_ptr,
- index_len / 2,
- );
- this.gl.bufferData(
- this.gl.ELEMENT_ARRAY_BUFFER,
- indices,
- this.gl.DYNAMIC_DRAW,
- );
-
- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
- const vertexes = this.bytesFromPointer(vertex_ptr, vertex_len)
- this.gl.bufferData(
- this.gl.ARRAY_BUFFER,
- vertexes,
- this.gl.DYNAMIC_DRAW,
- );
-
- let matrix = new Float32Array(16);
- matrix[0] = 2.0 / this.renderTargetSize[0];
- matrix[1] = 0.0;
- matrix[2] = 0.0;
- matrix[3] = 0.0;
- matrix[4] = 0.0;
- if (this.using_fb) {
- matrix[5] = 2.0 / this.renderTargetSize[1];
- } else {
- matrix[5] = -2.0 / this.renderTargetSize[1];
- }
- matrix[6] = 0.0;
- matrix[7] = 0.0;
- matrix[8] = 0.0;
- matrix[9] = 0.0;
- matrix[10] = 1.0;
- matrix[11] = 0.0;
- matrix[12] = -1.0;
- if (this.using_fb) {
- matrix[13] = -1.0;
- } else {
- matrix[13] = 1.0;
- }
- matrix[14] = 0.0;
- matrix[15] = 1.0;
-
- // vertex
- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
- this.gl.vertexAttribPointer(
- this.programInfo.attribLocations.vertexPosition,
- 2, // num components
- this.gl.FLOAT,
- false, // don't normalize
- sizeof_vertex, // stride
- offset_pos, // offset
- );
- this.gl.enableVertexAttribArray(
- this.programInfo.attribLocations.vertexPosition,
- );
-
- // color
- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
- this.gl.vertexAttribPointer(
- this.programInfo.attribLocations.vertexColor,
- 4, // num components
- this.gl.UNSIGNED_BYTE,
- false, // don't normalize
- sizeof_vertex, // stride
- offset_col, // offset
- );
- this.gl.enableVertexAttribArray(
- this.programInfo.attribLocations.vertexColor,
- );
-
- // texture
- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
- this.gl.vertexAttribPointer(
- this.programInfo.attribLocations.textureCoord,
- 2, // num components
- this.gl.FLOAT,
- false, // don't normalize
- sizeof_vertex, // stride
- offset_uv, // offset
- );
- this.gl.enableVertexAttribArray(
- this.programInfo.attribLocations.textureCoord,
- );
-
- // Tell WebGL to use our program when drawing
- this.gl.useProgram(this.shaderProgram);
-
- // Set the shader uniforms
- this.gl.uniformMatrix4fv(
- this.programInfo.uniformLocations.matrix,
- false,
- matrix,
- );
-
- if (textureId != 0) {
- const tex = this.textureEntry(textureId);
- if (tex !== null) {
- this.gl.activeTexture(this.gl.TEXTURE0);
- this.gl.bindTexture(this.gl.TEXTURE_2D, tex[0]);
- this.gl.uniform1i(
- this.programInfo.uniformLocations.useTex,
- 1,
- );
- } else {
- console.warn(
- `wasm_renderGeometry: missing texture id ${textureId}`,
- );
- this.gl.bindTexture(this.gl.TEXTURE_2D, null);
- this.gl.uniform1i(
- this.programInfo.uniformLocations.useTex,
- 0,
- );
- }
- } else {
- this.gl.bindTexture(this.gl.TEXTURE_2D, null);
- this.gl.uniform1i(
- this.programInfo.uniformLocations.useTex,
- 0,
- );
- }
-
- this.gl.uniform1i(
- this.programInfo.uniformLocations.uSampler,
- 0,
- );
-
- //console.log("drawElements " + textureId);
- this.gl.drawElements(
- this.gl.TRIANGLES,
- indices.length,
- this.gl.UNSIGNED_SHORT,
- 0,
- );
-
- if (clip === 1) {
- //gl.scissor(old_scissor[0], old_scissor[1], old_scissor[2], old_scissor[3]);
- this.gl.scissor(
- 0,
- 0,
- this.renderTargetSize[0],
- this.renderTargetSize[1],
- );
- }
- },
- wasm_cursor: (name_ptr, name_len) => {
- const cursor_name = this.stringFromPointer(name_ptr, name_len);
- this.gl.canvas.style.cursor = cursor_name;
- },
- wasm_text_input: (x, y, w, h) => {
- if (w > 0 && h > 0) {
- this.textInputRect = [x, y, w, h];
- } else {
- this.textInputRect = [];
- }
- },
- wasm_open_url: (ptr, len, new_win) => {
- const url = this.stringFromPointer(ptr, len);
-
- if (new_win) {
- window.open(url);
- } else {
- window.location.href = url;
- }
- },
- wasm_preferred_color_scheme: () => {
- if (
- window.matchMedia("(prefers-color-scheme: dark)").matches
- ) {
- return 1;
- }
- if (
- window.matchMedia("(prefers-color-scheme: light)").matches
- ) {
- return 2;
- }
- return 0;
- },
- wasm_prefers_reduced_motion: () => {
- if (
- window.matchMedia("(prefers-reduced-motion: no-preference)").matches
- ) {
- return 0;
- }
- if (
- window.matchMedia("(prefers-reduced-motion: reduce)").matches
- ) {
- return 1;
- }
- return 0;
- },
- wasm_download_data: (
- name_ptr,
- name_len,
- data_ptr,
- data_len,
- ) => {
- const name = this.stringFromPointer(name_ptr, name_len);
- const data = this.bytesFromPointer(data_ptr, data_len);
- const blob = new Blob([data], { type: "application/octet-stream" });
- const fileURL = URL.createObjectURL(blob);
- const dl = document.createElement("a");
- dl.href = fileURL;
- dl.download = name;
- dl.click();
- dl.remove();
- URL.revokeObjectURL(fileURL);
- },
- wasm_open_file_picker: (id, accept_ptr, accept_len, multiple) => {
- const accept = this.stringFromPointer(accept_ptr, accept_len);
- // console.log("Open picker", accept_ptr, accept_len, accept, multiple);
- dvui_open_file_picker(accept, multiple).then((filelist) => {
- let files = [];
- let data = [];
- for (let i = 0; i < filelist.length; i++) {
- const file = filelist.item(i);
- files.push(file);
- data.push(file.arrayBuffer());
- }
- Promise.all(data).then((data) => {
- this.filesCacheModified = true;
- this.filesCache.set(id, { files, data });
- this.requestRender();
- });
- }).catch(() => {
- console.debug(
- "Filepicker canceled: This is currently not detectable from within dvui",
- );
- this.requestRender();
- });
- },
- wasm_get_file_size: (id, file_index) => {
- const cached = this.filesCache.get(id);
- if (!cached || cached.files.length <= file_index) return;
- const size = cached.files[file_index].size;
- return size;
- },
- wasm_get_file_name: (id, file_index) => {
- const cached = this.filesCache.get(id);
- if (!cached || cached.files.length <= file_index) return;
-
- return this.allocStringZ(this.instance.exports.arena_u8, cached.files[file_index].name);
- },
- wasm_read_file_data: (id, file_index, data_ptr) => {
- const cached = this.filesCache.get(id);
- if (!cached || cached.files.length <= file_index) return;
- var dest = new Uint8Array(
- this.instance.exports.memory.buffer,
- data_ptr,
- );
- dest.set(new Uint8Array(cached.data[file_index]));
- },
- wasm_get_number_of_files_available: (id) => {
- const cached = this.filesCache.get(id);
- if (!cached) return 0;
- return cached.files.length;
- },
- wasm_clipboardTextSet: (ptr, len) => {
- if (len == 0) {
- return;
- }
-
- const msg = this.stringFromPointer(ptr, len)
- if (navigator.clipboard) {
- navigator.clipboard.writeText(msg);
- } else {
- this.hidden_input.value = msg;
- this.hidden_input.focus();
- this.hidden_input.select();
- document.execCommand("copy");
- this.hidden_input.value = "";
- }
- },
- wasm_add_noto_font: () => {
- dvui_fetch("NotoSansKR-Regular.ttf").then((bytes) => {
- //console.log("bytes len " + bytes.length);
- const ptr = this.allocBuffer(this.instance.exports.gpa_u8, bytes)
- this.instance.exports.new_font(
- ptr,
- bytes.length,
- );
- });
- },
- };
+ super();
+ this.imports = this.buildImports();
}
setInstance(instance) {
@@ -1015,130 +106,191 @@ export class Dvui {
);
}
- // We do our own edge fades with triangles. If we leave antialias on
- // then you can get a faint brightness on the edge of a filled square.
- // Right on the edge you get the blended combination of both triangles
- // that share that edge.
- // It's not easy to notice, but to reproduce:
- // * turn antialias on
- // * use the builtin Adwaita Dark theme
- // * put a small filled (.background = true) box in a larger filled box
- // * screenshot it
- // * the edge of the small box will be brighter than the fill color
- this.gl = canvas.getContext("webgl2", { alpha: true, antialias: false });
- if (this.gl === null) {
- this.gl = canvas.getContext("webgl", { alpha: true, antialias: false });
+ if (!this.setupWebGL(canvas)) {
+ return;
}
- if (this.gl === null) {
+ this.hiddenInputMgr = new HiddenInputManager(canvas);
+ }
+
+ setupWebGL(canvas) {
+ const program = super.setupWebGL(canvas);
+ if (!program) {
alert("Unable to initialize WebGL.");
- return;
+ return null;
}
-
if (!this.webgl2) {
const ext = this.gl.getExtension("OES_element_index_uint");
if (ext === null) {
alert("WebGL doesn't support OES_element_index_uint.");
- return;
+ return null;
}
}
- this.frame_buffer = this.gl.createFramebuffer();
+ return program;
+ }
- const vertexShader = this.gl.createShader(this.gl.VERTEX_SHADER);
- if (this.webgl2) {
- this.gl.shaderSource(vertexShader, vertexShaderSource_webgl2);
- } else {
- this.gl.shaderSource(vertexShader, vertexShaderSource_webgl);
- }
- this.gl.compileShader(vertexShader);
- if (!this.gl.getShaderParameter(vertexShader, this.gl.COMPILE_STATUS)) {
- alert(
- `Error compiling vertex shader: ${this.gl.getShaderInfoLog(vertexShader)
- }`,
- );
- this.gl.deleteShader(vertexShader);
- return null;
+ wasm_panic(ptr, len) {
+ this.stop();
+ const msg = this.stringFromPointer(ptr, len);
+ console.error("PANIC:", msg);
+ alert(msg);
+ }
+
+ wasm_sleep(ms) {
+ const end = Date.now() + ms;
+ while (Date.now() < end) {
+ // block because the point is to limit the framerate
}
+ }
+
+ wasm_refresh() {
+ this.requestRender();
+ }
+
+ wasm_pixel_width() {
+ return this.gl.drawingBufferWidth;
+ }
+
+ wasm_pixel_height() {
+ return this.gl.drawingBufferHeight;
+ }
+
+ wasm_canvas_width() {
+ return this.gl.canvas.clientWidth;
+ }
- const fragmentShader = this.gl.createShader(this.gl.FRAGMENT_SHADER);
- if (this.webgl2) {
- this.gl.shaderSource(fragmentShader, fragmentShaderSource_webgl2);
+ wasm_canvas_height() {
+ return this.gl.canvas.clientHeight;
+ }
+
+ wasm_cursor(name_ptr, name_len) {
+ const cursor_name = this.stringFromPointer(name_ptr, name_len);
+ this.gl.canvas.style.cursor = cursor_name;
+ }
+
+ wasm_text_input(x, y, w, h) {
+ this.hiddenInputMgr.setRect([x, y, w, h]);
+ }
+
+ wasm_open_url(ptr, len, new_win) {
+ const url = this.stringFromPointer(ptr, len);
+
+ if (new_win) {
+ window.open(url);
} else {
- this.gl.shaderSource(fragmentShader, fragmentShaderSource_webgl);
+ window.location.href = url;
}
- this.gl.compileShader(fragmentShader);
+ }
+
+ wasm_preferred_color_scheme() {
if (
- !this.gl.getShaderParameter(fragmentShader, this.gl.COMPILE_STATUS)
+ window.matchMedia("(prefers-color-scheme: dark)").matches
) {
- alert(
- `Error compiling fragment shader: ${this.gl.getShaderInfoLog(fragmentShader)
- }`,
- );
- this.gl.deleteShader(fragmentShader);
- return null;
+ return 1;
}
+ if (
+ window.matchMedia("(prefers-color-scheme: light)").matches
+ ) {
+ return 2;
+ }
+ return 0;
+ }
- this.shaderProgram = this.gl.createProgram();
- this.gl.attachShader(this.shaderProgram, vertexShader);
- this.gl.attachShader(this.shaderProgram, fragmentShader);
- this.gl.linkProgram(this.shaderProgram);
-
+ wasm_prefers_reduced_motion() {
if (
- !this.gl.getProgramParameter(
- this.shaderProgram,
- this.gl.LINK_STATUS,
- )
+ window.matchMedia("(prefers-reduced-motion: no-preference)").matches
) {
- alert(
- `Error initializing shader program: ${this.gl.getProgramInfoLog(this.shaderProgram)
- }`,
- );
- return null;
+ return 0;
}
+ if (
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ ) {
+ return 1;
+ }
+ return 0;
+ }
- this.programInfo = {
- attribLocations: {
- vertexPosition: this.gl.getAttribLocation(
- this.shaderProgram,
- "aVertexPosition",
- ),
- vertexColor: this.gl.getAttribLocation(
- this.shaderProgram,
- "aVertexColor",
- ),
- textureCoord: this.gl.getAttribLocation(
- this.shaderProgram,
- "aTextureCoord",
- ),
- },
- uniformLocations: {
- matrix: this.gl.getUniformLocation(
- this.shaderProgram,
- "uMatrix",
- ),
- uSampler: this.gl.getUniformLocation(
- this.shaderProgram,
- "uSampler",
- ),
- useTex: this.gl.getUniformLocation(
- this.shaderProgram,
- "useTex",
- ),
- },
- };
+ wasm_open_file_picker(id, accept_ptr, accept_len, multiple) {
+ const accept = this.stringFromPointer(accept_ptr, accept_len);
+ // console.log("Open picker", accept_ptr, accept_len, accept, multiple);
+ dvui_open_file_picker(accept, multiple).then((filelist) => {
+ let files = [];
+ let data = [];
+ for (let i = 0; i < filelist.length; i++) {
+ const file = filelist.item(i);
+ files.push(file);
+ data.push(file.arrayBuffer());
+ }
+ Promise.all(data).then((data) => {
+ this.filesCacheModified = true;
+ this.filesCache.set(id, { files, data });
+ this.requestRender();
+ });
+ }).catch(() => {
+ console.debug(
+ "Filepicker canceled: This is currently not detectable from within dvui",
+ );
+ this.requestRender();
+ });
+ }
- this.indexBuffer = this.gl.createBuffer();
- this.vertexBuffer = this.gl.createBuffer();
+ wasm_get_file_size(id, file_index) {
+ const cached = this.filesCache.get(id);
+ if (!cached || cached.files.length <= file_index) return;
+ const size = cached.files[file_index].size;
+ return size;
+ }
- this.gl.enable(this.gl.BLEND);
- this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA);
- this.gl.enable(this.gl.SCISSOR_TEST);
- this.gl.scissor(
- 0,
- 0,
- this.gl.canvas.clientWidth,
- this.gl.canvas.clientHeight,
+ wasm_get_file_name(id, file_index) {
+ const cached = this.filesCache.get(id);
+ if (!cached || cached.files.length <= file_index) return;
+
+ return this.allocStringZ(this.instance.exports.arena_u8, cached.files[file_index].name);
+ }
+
+ wasm_read_file_data(id, file_index, data_ptr) {
+ const cached = this.filesCache.get(id);
+ if (!cached || cached.files.length <= file_index) return;
+ var dest = new Uint8Array(
+ this.instance.exports.memory.buffer,
+ data_ptr,
);
+ dest.set(new Uint8Array(cached.data[file_index]));
+ }
+
+ wasm_get_number_of_files_available(id) {
+ const cached = this.filesCache.get(id);
+ if (!cached) return 0;
+ return cached.files.length;
+ }
+
+ wasm_clipboardTextSet(ptr, len) {
+ if (len == 0) {
+ return;
+ }
+
+ const msg = this.stringFromPointer(ptr, len)
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(msg);
+ } else {
+ const hiddenInput = this.hiddenInputMgr.hiddenInput;
+ hiddenInput.value = msg;
+ hiddenInput.focus();
+ hiddenInput.select();
+ document.execCommand("copy");
+ hiddenInput.value = "";
+ }
+ }
+
+ wasm_add_noto_font() {
+ dvui_fetch("NotoSansKR-Regular.ttf").then((bytes) => {
+ //console.log("bytes len " + bytes.length);
+ const ptr = this.allocBuffer(this.instance.exports.gpa_u8, bytes)
+ this.instance.exports.new_font(
+ ptr,
+ bytes.length,
+ );
+ });
}
init() {
@@ -1235,9 +387,9 @@ export class Dvui {
let millis_to_wait = this.instance.exports.dvui_update();
- // This oskCheck is for desktop to get text events. Touch devices will
+ // This check is for desktop to get text events. Touch devices will
// show/hide the keyboard (but not all, see touchend handler).
- this.oskCheck();
+ this.hiddenInputMgr.check();
if (!this.filesCacheModified) {
// Only clear if we didn't add anything this frame. Async could add items after they were requested
@@ -1311,90 +463,12 @@ export class Dvui {
if (this.stopped) return;
ev.preventDefault();
- // If we haven't gotten a wheel event in a second, reset our first
- // because the user might have switched between mouse and touchpad.
- if ((Date.now() - this.scroll_last_ms) > 1000) {
- this.scroll_lowest_batch = [99999, 99999];
- }
- this.scroll_last_ms = Date.now();
-
- const touchpad_adj = 0.025;
-
- if (ev.deltaX != 0) {
- this.scroll_lowest[0] = Math.min(
- Math.abs(ev.deltaX),
- this.scroll_lowest[0],
- );
- this.scroll_lowest_batch[0] = Math.min(
- Math.abs(ev.deltaX),
- this.scroll_lowest_batch[0],
- );
- var ticks = -ev.deltaX;
- var trackpad = 0;
- if (ev.deltaMode !== 0) {
- // only mouse wheels produce non-pixel deltas, so this is definitive without
- // needing the magnitude heuristic.
- ticks /= this.scroll_lowest_batch[0];
- } else if ((this.scroll_lowest_batch[0] >= 100) || // most wheels
- (this.scroll_lowest_batch[0] === 16) || // mac firefox
- (this.scroll_lowest_batch[0] === 9) || // mac firefox holding shift
- (this.scroll_lowest_batch[0] === 40) || // mac safari/chrome holding shift
- (this.scroll_lowest_batch[0] === 4.000244140625)) { // mac safari/chrome
- // assume this is a mouse wheel
- ticks /= this.scroll_lowest_batch[0];
- if (this.scroll_lowest_batch[0] === 4.000244140625) {
- ticks *= touchpad_adj; // mac safari/chrome scale wheel like touchpad
- }
- //console.log("wheelX -deltaX " + -ev.deltaX + " ticks " + ticks);
- } else {
- // assume touchpad
- trackpad = 1;
- ticks = ticks / this.scroll_lowest[0] * touchpad_adj;
- //console.log("touchpadX -deltaX " + -ev.deltaX + " ticks " + ticks);
- }
- this.instance.exports.add_event(
- 4,
- 0,
- trackpad,
- ticks,
- 0,
- );
- }
- if (ev.deltaY != 0) {
- //console.log("deltaMode: " + ev.deltaMode + " deltaY: " + ev.deltaY);
- this.scroll_lowest[1] = Math.min(
- Math.abs(ev.deltaY),
- this.scroll_lowest[1],
- );
- this.scroll_lowest_batch[1] = Math.min(
- Math.abs(ev.deltaY),
- this.scroll_lowest_batch[1],
- );
- var ticks = -ev.deltaY;
- var trackpad = 0;
- if (ev.deltaMode !== 0) {
- // only mouse wheels produce non-pixel deltas
- ticks /= this.scroll_lowest_batch[1];
- } else if ((this.scroll_lowest_batch[1] >= 100) || // most wheels
- (this.scroll_lowest_batch[1] === 16) || // mac firefox
- (this.scroll_lowest_batch[1] === 4.000244140625)) { // mac safari/chrome
- // assume this is a mouse wheel
- ticks /= this.scroll_lowest_batch[1];
- if (this.scroll_lowest_batch[1] === 4.000244140625) {
- ticks *= touchpad_adj; // mac safari/chrome scale wheel like touchpad
- }
- //console.log("wheelY -deltaY " + -ev.deltaY + " ticks " + ticks);
- } else {
- // assume touchpad
- trackpad = 1;
- ticks = ticks / this.scroll_lowest[1] * touchpad_adj;
- //console.log("touchpadY -deltaY " + -ev.deltaY + " ticks " + ticks);
- }
+ for (const action of this.wheelHandler.processWheelEvent(ev)) {
this.instance.exports.add_event(
4,
- 1,
- trackpad,
- ticks,
+ action.axis,
+ action.trackpad,
+ action.ticks,
0,
);
}
@@ -1423,14 +497,13 @@ export class Dvui {
ptr,
str.length,
ev.repeat,
- (ev.metaKey << 3) + (ev.altKey << 2) +
- (ev.ctrlKey << 1) + (ev.shiftKey << 0),
+ encodeModifiers(ev),
);
this.requestRender();
}
};
this.gl.canvas.addEventListener("keydown", keydown.bind(this));
- this.hidden_input.addEventListener("keydown", keydown.bind(this));
+ this.hiddenInputMgr.hiddenInput.addEventListener("keydown", keydown.bind(this));
let keyup = (ev) => {
if (this.stopped) return;
@@ -1441,15 +514,14 @@ export class Dvui {
ptr,
str.length,
0,
- (ev.metaKey << 3) + (ev.altKey << 2) + (ev.ctrlKey << 1) +
- (ev.shiftKey << 0),
+ encodeModifiers(ev),
);
this.requestRender();
};
this.gl.canvas.addEventListener("keyup", keyup.bind(this));
- this.hidden_input.addEventListener("keyup", keyup.bind(this));
+ this.hiddenInputMgr.hiddenInput.addEventListener("keyup", keyup.bind(this));
- this.hidden_input.addEventListener("beforeinput", (ev) => {
+ this.hiddenInputMgr.hiddenInput.addEventListener("beforeinput", (ev) => {
if (this.stopped) return;
ev.preventDefault();
if (ev.data && !ev.isComposing) {
@@ -1465,7 +537,7 @@ export class Dvui {
this.requestRender();
}
});
- this.hidden_input.addEventListener("compositionend", (ev) => {
+ this.hiddenInputMgr.hiddenInput.addEventListener("compositionend", (ev) => {
if (this.stopped) return;
if (ev.data) {
const str = utf8encoder.encode(ev.data);
@@ -1488,11 +560,8 @@ export class Dvui {
let rect = this.gl.canvas.getBoundingClientRect();
for (let i = 0; i < ev.changedTouches.length; i++) {
let touch = ev.changedTouches[i];
- let x = (touch.clientX - rect.left) /
- (rect.right - rect.left);
- let y = (touch.clientY - rect.top) /
- (rect.bottom - rect.top);
- let tidx = this.touchIndex(touch.identifier);
+ let [x, y] = getTouchCoords(touch, rect);
+ let tidx = touchIndex(this.touches, touch.identifier);
this.instance.exports.add_event(
8,
this.touches[tidx][1],
@@ -1509,11 +578,8 @@ export class Dvui {
let rect = this.gl.canvas.getBoundingClientRect();
for (let i = 0; i < ev.changedTouches.length; i++) {
let touch = ev.changedTouches[i];
- let x = (touch.clientX - rect.left) /
- (rect.right - rect.left);
- let y = (touch.clientY - rect.top) /
- (rect.bottom - rect.top);
- let tidx = this.touchIndex(touch.identifier);
+ let [x, y] = getTouchCoords(touch, rect);
+ let tidx = touchIndex(this.touches, touch.identifier);
this.instance.exports.add_event(
9,
this.touches[tidx][1],
@@ -1523,10 +589,10 @@ export class Dvui {
);
this.touches.splice(tidx, 1);
}
- // This oskCheck is for some platforms (iphone) where showing
+ // This check is for some platforms (iphone) where showing
// the keyboard has to be done inside an event handler.
// https://stackoverflow.com/a/6837575
- this.oskCheck();
+ this.hiddenInputMgr.check();
this.requestRender();
});
this.gl.canvas.addEventListener("touchmove", (ev) => {
@@ -1535,11 +601,8 @@ export class Dvui {
let rect = this.gl.canvas.getBoundingClientRect();
for (let i = 0; i < ev.changedTouches.length; i++) {
let touch = ev.changedTouches[i];
- let x = (touch.clientX - rect.left) /
- (rect.right - rect.left);
- let y = (touch.clientY - rect.top) /
- (rect.bottom - rect.top);
- let tidx = this.touchIndex(touch.identifier);
+ let [x, y] = getTouchCoords(touch, rect);
+ let tidx = touchIndex(this.touches, touch.identifier);
this.instance.exports.add_event(
10,
this.touches[tidx][1],
diff --git a/src/backends/web.zig b/src/backends/web.zig
index 39181ac5e..c697e6db4 100644
--- a/src/backends/web.zig
+++ b/src/backends/web.zig
@@ -39,6 +39,10 @@ pub const wasm = if (!builtin.is_test) struct {
pub extern "dvui" fn wasm_sleep(ms: u32) void;
pub extern "dvui" fn wasm_refresh() void;
+ // Standalone/Worker mode: Atomics-based blocking wait for events.
+ // Returns 1 if woken by an event, 0 on timeout.
+ pub extern "dvui" fn wasm_wait_event(timeout_ms: i32) u32;
+
pub extern "dvui" fn wasm_pixel_width() f32;
pub extern "dvui" fn wasm_pixel_height() f32;
pub extern "dvui" fn wasm_canvas_width() f32;
@@ -52,6 +56,7 @@ pub const wasm = if (!builtin.is_test) struct {
pub extern "dvui" fn wasm_renderTarget(u32) void;
pub extern "dvui" fn wasm_textureDestroy(u32) void;
pub extern "dvui" fn wasm_renderGeometry(texture: u32, index_ptr: [*]const u8, index_len: usize, vertex_ptr: [*]const u8, vertex_len: usize, sizeof_vertex: u8, offset_pos: u8, offset_col: u8, offset_uv: u8, clip: u8, x: i32, y: i32, w: i32, h: i32) void;
+ pub extern "dvui" fn wasm_send_offscreencanvas_bitmap() void;
pub extern "dvui" fn wasm_cursor(name: [*]const u8, name_len: usize) void;
pub extern "dvui" fn wasm_text_input(x: f32, y: f32, w: f32, h: f32) void;
@@ -84,6 +89,10 @@ pub const wasm = if (!builtin.is_test) struct {
pub fn wasm_sleep(_: u32) void {}
pub fn wasm_refresh() void {}
+ pub fn wasm_wait_event(_: i32) u32 {
+ return 0;
+ }
+
pub fn wasm_pixel_width() f32 {
return undefined;
}
@@ -111,6 +120,7 @@ pub const wasm = if (!builtin.is_test) struct {
pub fn wasm_renderTarget(_: u32) void {}
pub fn wasm_textureDestroy(_: u32) void {}
pub fn wasm_renderGeometry(_: u32, _: [*]const u8, _: usize, _: [*]const u8, _: usize, _: u8, _: u8, _: u8, _: u8, _: u8, _: i32, _: i32, _: i32, _: i32) void {}
+ pub fn wasm_send_offscreencanvas_bitmap() void {}
pub fn wasm_cursor(_: [*]const u8, _: usize) void {}
pub fn wasm_text_input(_: f32, _: f32, _: f32, _: f32) void {}
@@ -794,8 +804,38 @@ pub fn setCursor(self: *WebBackend, cursor: dvui.enums.Cursor) void {
wasm.wasm_cursor(name.ptr, name.len);
}
+pub const InitOptions = struct {
+ allocator: std.mem.Allocator = std.heap.wasm_allocator,
+ size: dvui.Size = .{ .w = 800.0, .h = 600.0 },
+ min_size: ?dvui.Size = null,
+ max_size: ?dvui.Size = null,
+ vsync: bool = true,
+ title: [:0]const u8 = "DVUI Web Standalone",
+ icon: ?[]const u8 = null,
+};
+
+/// Create and initialize a web backend for standalone mode.
+/// Mirrors `SDLBackend.initWindow()`, but all options are ignored
+pub fn initWindow(options: InitOptions) !WebBackend {
+ _ = options; // size/vsync/title handled by HTML/CSS and JS
+ return WebBackend.init();
+}
+
+/// Block the current thread (Worker) until an event arrives or the
+/// timeout expires. Returns true if woken by an event.
+/// Set timeout_micros to `std.math.maxInt(u32)` to wait forever.
+pub fn waitEventTimeout(_: *WebBackend, timeout_micros: u32) !bool {
+ if (timeout_micros == std.math.maxInt(u32)) {
+ // Wait forever (until event)
+ return wasm.wasm_wait_event(-1) != 0;
+ }
+ const timeout_ms: i32 = @intCast(@min((timeout_micros + 999) / 1000, std.math.maxInt(u31)));
+ return wasm.wasm_wait_event(timeout_ms) != 0;
+}
+
pub fn renderPresent(_: *WebBackend) void {
- // satisfy Backend.zig interface
+ // In standalone mode, send the OffscreenCanvas bitmap to the main thread
+ wasm.wasm_send_offscreencanvas_bitmap();
}
pub fn openFilePicker(id: dvui.Id, accept: ?[]const u8, multiple: bool) void {