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 {